Call another agent
Call a hosted agent or a remote A2A peer through the gateway from code, act on the caller's behalf with delegation, and see what the audit ledger records.
Agents call other agents over agent-to-agent (A2A) v1.0. MAQPNA puts those calls through the same MAQPNA gateway as tool calls: the caller sends A2A JSON-RPC to {gateway}/a2a/{namespace}/{agent} with its session token, and the gateway authenticates it, checks the kill switch for the whole delegation chain, checks scope and allowed callers, runs data loss prevention (DLP), policy, budgets and approvals, and records the call twice in the audit ledger: once for the caller and once for the callee.
sequenceDiagram
autonumber
participant P as planner (caller)
participant GW as Gateway
participant R as reviewer (hosted callee)
participant IB as Identity broker
P->>GW: POST /a2a/team-a/reviewer SendMessage<br/>Bearer planner's token (scope agents:reviewer)
GW->>GW: kill switch (caller + chain), scope, allowedCallers,<br/>depth, loop, skill, DLP, policy, budgets
GW->>R: SendMessage + X-Maqpna-* identity headers<br/>+ Txn-Token (when enabled)
R->>IB: POST /v1/exchange (Txn-Token + reviewer's own token)
IB-->>R: delegated token: acts for planner, for alice
R->>GW: tools/call with the delegated token
GW-->>R: tool result
R-->>GW: A2A result
GW-->>P: A2A result
There is no A2A helper in the SDKs yet. Use any HTTP client with the session token, as shown below.
Targets#
| Target | Declared by | Default trust |
|---|---|---|
| Hosted agent, in the same installation | Agent.spec.a2a with expose: true and endpoint (the agent's in-cluster A2A URL) |
trusted |
| Remote peer, outside the installation | An A2APeer with url, pinned card keys (cardKeys) and allowedCallers |
untrusted: the caller's session is tainted untrusted-input by the answer |
Both are reached at /a2a/<namespace>/<name>. List the routes the gateway has with maqpna a2a routes.
Step 1: Expose the callee#
The callee runs an A2A JSON-RPC server inside its sandbox, behind a Service. Declare it on the callee's Agent:
apiVersion: maqpna.com/v1alpha1
kind: Agent
metadata:
name: reviewer
namespace: team-a
spec:
image: registry.example.eu/agents/reviewer:0.3.0
tier: tier-0-gvisor
tools:
- name: github
serverRef: {name: github}
a2a:
expose: true
endpoint: http://reviewer-a2a.team-a.svc:9000/a2a
description: Reviews pull requests.
skills:
- id: review
name: Review a pull request
allowedCallers: ["planner"] # same namespace; "<ns>/<agent>" across namespaces
timeoutSeconds: 300
When skills is set, a request for another skill is denied (unknown_skill). An empty allowedCallers admits only agents in the callee's namespace. Field reference: Agent CRD.
Step 2: Give the caller the scope#
The caller's token needs agents:reviewer (or agents:team-a/reviewer from another namespace); tools:* never grants it. Once you set spec.scopes, the automatic tools:<name> scopes are no longer added, so list them too:
apiVersion: maqpna.com/v1alpha1
kind: Agent
metadata:
name: planner
namespace: team-a
spec:
image: registry.example.eu/agents/planner:0.3.0
tier: tier-0-gvisor
tools:
- name: github
serverRef: {name: github}
scopes: [tools:github, agents:reviewer]
And allow the call in a policy. For A2A, the policy server is a2a/<agent> (a2a/<ns>/<agent> across namespaces) and the tool is the method (SendMessage, also used for SendStreamingMessage) or skill:<id>:
rules:
- name: planner-may-ask-reviewer
servers: ["a2a/reviewer"]
tools: ["SendMessage", "skill:review", "GetTask"]
action: allow
Step 3: Send a message#
The skill is selected with metadata.skillId on the message or on params (a MAQPNA convention; A2A has no standard selector).
import uuid, httpx
from maqpna import MaqpnaClient
client = MaqpnaClient.from_env()
url = f"{client.gateway_url}/a2a/team-a/reviewer"
body = {
"jsonrpc": "2.0", "id": "r1", "method": "SendMessage",
"params": {
"message": {"role": "ROLE_USER", "messageId": str(uuid.uuid4()),
"parts": [{"text": "Review acme/api#4821"}]},
"metadata": {"skillId": "review"},
},
}
resp = httpx.post(url, json=body, timeout=300,
headers={"Authorization": f"Bearer {client.token()}"}).json()
err = resp.get("error")
if err and (err.get("data") or {}).get("domain") == "maqpna.com":
print("refused by MAQPNA:", err["data"]["reason"]) # caller_not_allowed, delegation_depth, ...
elif err:
print("A2A error from the callee:", err)
else:
print(resp["result"])
import { randomUUID } from "node:crypto";
import { MaqpnaClient } from "@kosa8/maqpna";
const client = MaqpnaClient.fromEnv();
const res = await fetch(`${process.env.MAQPNA_GATEWAY_URL}/a2a/team-a/reviewer`, {
method: "POST",
headers: { Authorization: `Bearer ${await client.token()}`, "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0", id: "r1", method: "SendMessage",
params: {
message: { role: "ROLE_USER", messageId: randomUUID(), parts: [{ text: "Review acme/api#4821" }] },
metadata: { skillId: "review" },
},
}),
});
const out = await res.json();
if (out.error?.data?.domain === "maqpna.com") console.log("refused:", out.error.data.reason);
tok, err := client.Token(ctx)
if err != nil {
return err
}
body := `{"jsonrpc":"2.0","id":"r1","method":"SendMessage","params":{` +
`"message":{"role":"ROLE_USER","messageId":"m1","parts":[{"text":"Review acme/api#4821"}]},` +
`"metadata":{"skillId":"review"}}}`
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
client.GatewayURL()+"/a2a/team-a/reviewer", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := client.HTTPClient().Do(req)
curl -sS "$MAQPNA_GATEWAY_URL/a2a/team-a/reviewer" \
-H "Authorization: Bearer $(cat "$MAQPNA_TOKEN_FILE")" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":"r1","method":"SendMessage","params":{"message":{"role":"ROLE_USER","messageId":"m1","parts":[{"text":"Review acme/api#4821"}]},"metadata":{"skillId":"review"}}}'
A2A uses the same numeric JSON-RPC codes for its own errors (-32001 is TaskNotFound in A2A), so check error.data.domain: maqpna.com means the gateway refused the call. A refusal looks like this:
{"jsonrpc":"2.0","id":3,"error":{"code":-32001,"message":"denied by policy a2a","data":{"domain":"maqpna.com","policy":"a2a","reason":"caller_not_allowed","rule":"allowed-callers"}}}
The callee's agent card is at GET {gateway}/a2a/team-a/reviewer/.well-known/agent-card.json, signed by the gateway. The HTTP+JSON binding (/a2a/{ns}/{agent}/message:send, tasks/{id}, ...) is accepted too; approvals (X-Maqpna-Async, X-Maqpna-Approval-Id) work as for tool calls.
Step 4: Act on the caller's behalf (callee side)#
When the reviewer calls its own tools to answer, policy should still see the human the planner acts for. With the gateway's txnTokens enabled, the callee receives a Txn-Token header with the request. Exchange it, together with the callee's own session token, at the identity broker:
curl -sS -X POST "$BROKER_URL/v1/exchange" \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="$TXN_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:txn_token \
-d actor_token="$(cat /var/run/maqpna/token)" \
-d actor_token_type=urn:ietf:params:oauth:token-type:jwt
The answer is {"access_token": ..., "issued_token_type": ..., "token_type": ..., "expires_in": ...}. Use access_token as the bearer token for the tool calls that serve this message. Its act chain names the planner and the user, its scopes are narrowed to the reviewer's, and its lifetime is at most either token's (and at most 15 minutes by default). The operator does not inject the broker URL into the sandbox; get it from your platform team.
The broker refuses the exchange when the actor token is not a plain session token, when the chain would be deeper than 5, on self-delegation and across tenants.
What is recorded#
| Record | server |
Key ext fields |
|---|---|---|
| Caller's call | a2a/reviewer |
a2aHop: outbound, a2aCall (correlates both records) |
| Callee's view (hosted agents) | a2a/inbound/team-a/planner |
a2aHop: inbound, a2aCaller (the caller's SPIFFE ID) |
| Tool calls made with a delegated token | the tool's server | actChain: the planner's SPIFFE ID and user:alice@... |
Revoking any agent in the chain (maqpna kill agent planner ...) stops the delegated calls too, because the gateway checks every agent in the act chain.
What can go wrong#
| Reason | Meaning | Fix |
|---|---|---|
missing_scope |
The caller's token lacks agents:<name>. |
Add it to the caller's spec.scopes. |
caller_not_allowed |
The caller is not in the callee's allowedCallers. |
Add it there (or on the A2APeer). |
unknown_skill |
The skill is not declared. | Send a declared skillId. |
delegation_depth, delegation_loop |
The chain is too deep, or loops back. | Restructure the delegation. |
peer_card_unverified |
A remote peer's card is unsigned or signed by an unpinned key. | Fix cardKeys on the A2APeer. |
callee_revoked |
The callee is revoked. | See Kill switch and revocations. |
Next steps#
- A2A call with delegation, the full governance order
- MCP servers, tool pinning and A2A
- A2APeer CRD
- Test agents and policies