MAQPNADocs

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#