Two agents over A2A with delegation
Build a planner agent that hands work to a worker agent over agent-to-agent (A2A) through the MAQPNA gateway, with scoped tokens, allowed callers, a delegation token that keeps the human in the chain, and both hops in the audit ledger.
In this tutorial you build two agents that talk to each other over agent-to-agent (A2A) v1.0. A planner sends a task to a worker. The worker does the task with its own tools, but acting for the planner and for the person the planner works for. Every hop goes through the MAQPNA gateway, so the call between the agents is scoped, allow-listed, checked by policy and written to the audit ledger, like a tool call.
You do all of it on your laptop with a local MAQPNA. At the end you see the same setup as cluster resources (Agent.spec.a2a, A2APeer) and what is different there.
What you'll build#
sequenceDiagram
autonumber
participant P as planner<br/>(agent, user alice)
participant GW as MAQPNA gateway
participant W as worker<br/>(A2A server :9100)
participant IB as identity broker
participant E as mcp-echo
P->>GW: POST /a2a/dev/worker SendMessage<br/>skill cleanup, scope agents:worker
GW->>GW: scope, allowed callers, policy,<br/>audit (outbound hop)
GW->>W: SendMessage + Txn-Token
W->>IB: POST /v1/exchange<br/>Txn-Token + worker's own token
IB-->>W: delegation token<br/>act: planner, then alice
W->>GW: tools/call echo.* with the delegation token
GW->>E: allowed calls only
GW-->>W: results and denials
W-->>GW: answer
GW->>GW: audit (inbound hop)
GW-->>P: answer
Time: about 40 minutes. Level: advanced (you have run an agent under maqpna dev run before). You learn: A2A routes, agents:<name> scopes, allowedCallers, skills as policy tools, Txn-Tokens, delegation tokens, the chain in the audit ledger, and revoking the planner.
New to the ideas? Read Identity and delegation and MCP servers, tool pinning and A2A first. The full gateway sequence is in A2A call with delegation.
Prerequisites#
- The
maqpnaCLI (see Install) and free ports 8080, 8081, 8090 and 9100. - Python 3.10 or later, and
jq. - The MAQPNA Python SDK. It installs from a checkout of the source (from maqpna.com/github):
mkdir a2a-demo && cd a2a-demo
python3 -m venv .venv && . .venv/bin/activate
pip install kosa8-maqpna
The SDK brings httpx, which both scripts use. The SDK has no A2A client, so the planner sends A2A JSON-RPC with httpx directly.
Step 1: Declare the worker as an A2A route#
The gateway reaches agents through an A2A routing table. In a cluster the operator renders it from Agent.spec.a2a and A2APeer resources. Locally you write it yourself. Save this as a2a.json:
{
"agents": {
"dev/worker": {
"url": "http://127.0.0.1:9100/",
"name": "Worker",
"description": "Runs cleanup tasks for other agents",
"skills": [
{"id": "cleanup", "name": "Cleanup", "description": "Check and clean up resources", "tags": ["ops"]}
],
"allowedCallers": ["planner"]
}
}
}
The key dev/worker is <namespace>/<agent>: agents call it at /a2a/dev/worker on the gateway. url is where the worker serves A2A JSON-RPC; only the gateway calls it. A request for a skill not in skills is denied with unknown_skill. allowedCallers takes <agent> (same namespace) or <namespace>/<agent>, globs allowed; empty means agents of the same namespace.
Step 2: Turn on A2A and Txn-Tokens in the local gateway#
When the gateway forwards an A2A call to a hosted agent, it can attach a short-lived Transaction Token (Txn-Token header) that says who called, for whom, and for which target. The worker trades it at the identity broker for a delegation token. For the broker to accept it, the gateway must sign it with a key the broker trusts. Locally, use the broker's own key.
Write the gateway overrides with absolute paths:
cat > gateway-extra.json <<EOF
{
"a2a": {"routesFile": "$PWD/a2a.json"},
"txnTokens": {
"mode": "add",
"signingKey": "file://$PWD/.maqpna/identity.key",
"audience": "maqpna.local"
}
}
EOF
mode: add sends the Txn-Token next to the X-Maqpna-* identity headers (replace sends only the Txn-Token; off, the default, sends none). signingKey is a key URI; .maqpna/identity.key is the local broker's own key, which the broker always trusts. audience is the trust domain: the local broker's is maqpna.local, and it expects that audience in a Txn-Token. All gateway keys are in Gateway configuration.
Step 3: Write the policies#
A2A calls are policy-checked like tool calls. The server is a2a/<agent> and the tool is skill:<id> when the caller names a skill, otherwise SendMessage. Save as policies.json:
{
"policies": [
{"name": "planner-delegation", "namespace": "dev", "agents": ["planner"], "defaultAction": "deny",
"rules": [
{"name": "may-ask-worker", "servers": ["a2a/worker"], "tools": ["skill:cleanup"], "action": "allow"}
]},
{"name": "worker-tools", "namespace": "dev", "agents": ["worker"], "defaultAction": "deny",
"rules": [
{"name": "no-system-namespaces", "servers": ["echo"], "tools": ["*"],
"argMatch": {"namespace": "kube-*"}, "action": "deny",
"reason": "system namespaces are off-limits to agents"},
{"name": "read-only", "servers": ["echo"], "tools": ["echo", "get_time"], "action": "allow"}
]}
]
}
The planner may ask the worker for cleanup and nothing else. It has no tools of its own. The worker may read, never touch kube-*, and everything else is denied.
Check it works#
maqpna policy test --policy policies.json --ns dev --agent planner \
--server a2a/worker --tool skill:cleanup --expect allow
maqpna policy test --policy policies.json --ns dev --agent worker \
--server echo --tool delete_resource --args '{"namespace":"kube-system"}' --expect deny
Both exit with status 0 and print the deciding policy and rule.
Step 4: Start the local MAQPNA#
maqpna dev up --policy policies.json --gateway-config gateway-extra.json
This starts the identity broker on port 8081, the gateway on 8080 and mcp-echo on 8090. --gateway-config merges your file over the generated .maqpna/gateway.json.
Check it works#
In a second terminal in the same folder, load the local admin token and list the routes:
eval "$(maqpna dev env)"
maqpna a2a routes
The table (TARGET KIND URL TRUST JURISDICTION CALLERS CARD ERROR) has one row: target dev/worker, a hosted agent at http://127.0.0.1:9100/, trust trusted (the default for hosted agents), callers planner, and an empty ERROR column. If the row is missing, check .maqpna/logs/maqpna-gateway.log for a2a routes.
Step 5: Write and start the worker#
The worker is a small A2A JSON-RPC server. For each SendMessage it exchanges the Txn-Token for a delegation token, then calls mcp-echo through the gateway with that token. Save as worker.py:
#!/usr/bin/env python3
"""worker: an A2A agent that does its work on behalf of the agent that called it."""
import base64, json, os, sys, uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import httpx
from maqpna import MaqpnaClient, PolicyDenied
BROKER = os.environ.get("BROKER_URL", "http://127.0.0.1:8081")
PORT = int(os.environ.get("WORKER_PORT", "9100"))
def log(msg):
print(f"[worker] {msg}", file=sys.stderr, flush=True)
def claims(token):
part = token.split(".")[1]
return json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))
def act_chain(c):
chain, act = [], c.get("act")
while act:
chain.append(act["sub"])
act = act.get("act")
return chain
def delegate(txn_token):
"""Trade the call's Txn-Token plus our own session token for a delegation token."""
r = httpx.post(f"{BROKER}/v1/exchange", data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": txn_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:txn_token",
"actor_token": os.environ["MAQPNA_TOKEN"],
"actor_token_type": "urn:ietf:params:oauth:token-type:jwt",
"scope": "tools:echo",
})
if r.status_code != 200:
raise RuntimeError(f"delegation refused: {r.text}")
return r.json()["access_token"]
def cleanup(token, task):
echo = MaqpnaClient.from_env(token=token).tools("echo")
lines = [f"time: {echo.call('get_time').text}", f"echo: {echo.call('echo', text=task).text}"]
try:
echo.call("delete_resource", id="coredns", namespace="kube-system")
except PolicyDenied as e:
lines.append(f"kube-system delete refused by rule {e.rule}")
return "\n".join(lines)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
req = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
log(f"{req['method']} from agent {self.headers.get('X-Maqpna-Agent')} for user {self.headers.get('X-Maqpna-User')}")
task = " ".join(p.get("text", "") for p in req["params"]["message"]["parts"])
token = delegate(self.headers["Txn-Token"])
c = claims(token)
log(f"delegation token: agent={c['maqpna_agent']} act={act_chain(c)} scope={c.get('scope')}")
answer = cleanup(token, task)
body = json.dumps({"jsonrpc": "2.0", "id": req["id"], "result": {"message": {
"role": "ROLE_AGENT", "messageId": str(uuid.uuid4()), "parts": [{"text": answer}]}}}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
log(f"listening on 127.0.0.1:{PORT}")
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
Run it under maqpna dev run as agent worker, so it gets its own session token with scope tools:echo. Leave it running in this terminal:
maqpna dev run --agent worker --session worker-1 --scope tools:echo -- python worker.py
[worker] listening on 127.0.0.1:9100
Step 6: Write and run the planner#
The planner sends one A2A SendMessage to the gateway, naming the skill in message.metadata.skillId. Save as planner.py:
#!/usr/bin/env python3
"""planner: hands a task to the worker over A2A through the MAQPNA gateway."""
import os, sys, uuid
import httpx
gateway, token = os.environ["MAQPNA_GATEWAY_URL"], os.environ["MAQPNA_TOKEN"]
task = " ".join(sys.argv[1:]) or "check the cluster"
request = {"jsonrpc": "2.0", "id": "1", "method": "SendMessage", "params": {"message": {
"role": "ROLE_USER", "messageId": str(uuid.uuid4()),
"parts": [{"text": task}], "metadata": {"skillId": "cleanup"}}}}
r = httpx.post(f"{gateway}/a2a/dev/worker", json=request,
headers={"Authorization": f"Bearer {token}"}, timeout=60)
out = r.json()
if "error" in out:
data = out["error"].get("data") or {}
print(f"[planner] refused: {out['error']['message']} (reason={data.get('reason')})")
sys.exit(3)
for part in out["result"]["message"]["parts"]:
print(f"[planner] worker says:\n{part.get('text')}")
In the second terminal, run it as agent planner on behalf of [email protected], with the scope that lets it call the worker:
maqpna dev run --agent planner --session planner-1 --user [email protected] \
--scope agents:worker -- python planner.py "check the cluster"
The planner prints the worker's answer: the time, the echoed task, and kube-system delete refused by rule no-system-namespaces. In the worker's terminal you see the call arrive from agent planner for user [email protected], and the delegation token's act chain: the planner's SPIFFE ID (spiffe://maqpna.local/ns/dev/agent/planner/session/planner-1), then the user.
Check it works: the scope and caller checks#
Run the planner without the agents:worker scope:
maqpna dev run --agent planner --session planner-2 --user [email protected] \
--scope tools:echo -- python planner.py
It is refused with forbidden: token lacks scope agents:worker (HTTP 403). tools:* never grants an A2A target.
Now run an agent that has the scope but is not in allowedCallers:
maqpna dev run --agent intruder --session intruder-1 --scope agents:worker -- python planner.py
It is refused with reason caller_not_allowed (policy a2a, rule allowed-callers), before any policy rule is evaluated. The worker's terminal shows nothing: neither call reached it.
Step 7: Read the chain in the audit ledger#
Every decision is in .maqpna/audit.jsonl. Show the records of the A2A call and of the delegated calls:
jq -c 'select(.ext.a2aHop != null or .ext.actChain != null)
| {seq, agent, user, server, tool, decision, hop: .ext.a2aHop, call: .ext.a2aCall, chain: .ext.actChain}' \
.maqpna/audit.jsonl
For the successful run you see:
| Agent | Server | Tool | Decision | What it is |
|---|---|---|---|---|
planner |
a2a/worker |
skill:cleanup |
allow |
The caller's record, hop: outbound, rule planner-delegation/may-ask-worker |
worker |
echo |
get_time, echo |
allow |
The worker's calls with the delegation token; chain is the planner's SPIFFE ID, then the user |
worker |
echo |
delete_resource |
deny |
Rule worker-tools/no-system-namespaces, same chain |
worker |
a2a/inbound/dev/planner |
skill:cleanup |
allow |
The callee's record, hop: inbound, written when the answer came back |
The user on the worker's records is [email protected], not the worker's own user: the gateway walks the act chain to the user: entry, so policies that match people keep working across agents. The outbound and inbound records share the same call value (ext.a2aCall), so you can join the two hops; both carry ext.txn, the transaction ID. The refused calls from step 6 are there too, as deny records with hop: outbound.
Per-session views work as usual: maqpna dev timeline --session planner-1 and maqpna dev timeline --session worker-1. Finally, verify the chain:
maqpna audit verify .maqpna/audit.jsonl
It prints OK: N records, hash chain intact.
Step 8: Revoke the planner#
The kill switch checks the caller and every agent in its act chain. Revoke the planner:
maqpna kill agent planner -n dev --reason "tutorial: stop the planner" --yes
maqpna dev run --agent planner --session planner-3 --user [email protected] \
--scope agents:worker -- python planner.py
The planner is refused with a message that starts with revoked: and reason revoked. Delegation tokens whose chain contains the planner are refused the same way, so a worker cannot keep acting for a revoked caller. Lift it with maqpna kill agent planner -n dev --release --yes. See Kill switch and revocations.
Step 9: The same setup in a cluster#
In a cluster you do not write a2a.json. The operator renders it (ConfigMap maqpna-a2a, key a2a.json) from two kinds of resource:
| You want to call | Resource | Default trust | What the callee gets |
|---|---|---|---|
| An agent in your installation | Agent with spec.a2a.expose: true and spec.a2a.endpoint |
trusted |
X-Maqpna-* headers and, with Txn-Tokens on, a Txn-Token |
| An agent outside it | A2APeer with url, pinned card keys and allowedCallers |
untrusted |
No identity; the gateway authenticates with auth (none or tokenExchange) |
The worker and the planner as resources (namespace team-a):
apiVersion: maqpna.com/v1alpha1
kind: Agent
metadata: {name: worker, namespace: team-a}
spec:
image: registry.example.eu/agents/worker:0.1.0
tier: tier-0
tools: [{name: echo}]
policyRef: worker-tools
a2a:
expose: true
endpoint: http://worker.team-a.svc:9100/ # a Service in front of the worker's A2A port
description: Runs cleanup tasks for other agents
skills: [{id: cleanup, name: Cleanup, description: Check and clean up resources}]
allowedCallers: ["planner"]
---
apiVersion: maqpna.com/v1alpha1
kind: Agent
metadata: {name: planner, namespace: team-a}
spec:
image: registry.example.eu/agents/planner:0.1.0
tier: tier-0
policyRef: planner-delegation
scopes: ["agents:worker"] # agents:<ns>/<name> to call across namespaces
The policies are ToolPolicy resources with the same rules as policies.json (spec.agents, spec.defaultAction, spec.rules). Check everything offline with maqpna validate -f . and maqpna policy test, then maqpna apply -f ..
To call a partner's agent instead, register it as an A2APeer. Its agent card must be signed (JWS over RFC 8785) by a key you pin, or every call fails closed with peer_card_unverified. Answers from an untrusted peer taint the caller's session with untrusted-input:
apiVersion: maqpna.com/v1alpha1
kind: A2APeer
metadata: {name: partner-reviewer, namespace: team-a}
spec:
url: https://agents.partner.example.eu/a2a/v1
jurisdiction: EU-DE
trust: untrusted
cardKeys:
jwksConfigMapRef: {name: partner-reviewer-card-keys, key: jwks.json}
allowedCallers: ["planner"]
skills: ["code-review"]
auth:
type: tokenExchange
tokenExchange: {audience: partner-reviewer}
The planner calls it at /a2a/team-a/partner-reviewer with scope agents:partner-reviewer. Txn-Tokens and identity headers never go to a peer, so a peer cannot delegate back into your installation. Fields: A2APeer, Agent.
Txn-Tokens are off in the chart by default. Turn them on in your values, with an Ed25519 key in two Secrets (private key txn-signing.pem for the gateway, public key pub.pem for the broker; maqpna keys generate --alg Ed25519 --out txn-signing.pem writes both files):
gateway:
txnTokens:
mode: add
signingKeySecret: maqpna-txn-signing # key txn-signing.pem
identity:
txnTokenPublicKeySecret: maqpna-txn-pub # key pub.pem
See Helm values.
Inbound calls from remote peers into your hosted agents, and cross-organisation clearing, are Planned.
What you built#
- A worker exposed as an A2A route with one skill and one allowed caller, and a planner whose token carries
agents:worker. - Scope and caller checks that refuse a call before policy, and policies that treat
skill:cleanupona2a/workerlike any tool. - A delegation token whose act chain names the planner and the user, so the worker's tool calls are governed as "worker, for planner, for alice".
- Both hops in the audit ledger, joined by
ext.a2aCall, with the chain inext.actChain, and a kill switch that stops the chain at the planner.
Clean up#
Stop the worker with Ctrl-C, then run maqpna dev down. Delete .maqpna/ too if you want to remove the local keys, policy and ledger.
Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
unknown agent dev/worker (HTTP 404) |
The gateway has no route: a2a.json was not loaded or has an error. |
Check maqpna a2a routes and .maqpna/logs/maqpna-gateway.log; the path in gateway-extra.json must be absolute. |
The planner gets upstream_unavailable |
The worker is not running on port 9100. | Start it as in step 5. |
The worker logs KeyError: 'Txn-Token' |
Txn-Tokens are off. | Check txnTokens in .maqpna/gateway.json; restart with maqpna dev down and step 4. |
delegation refused: ... the transaction token was not issued for a call to dev/worker |
The worker runs under another agent name or namespace. | Run it with --agent worker (namespace dev). |
Every planner call is denied with unknown_skill |
skillId does not match a skill in a2a.json. |
Use cleanup, or add the skill to the route. |
Next steps#
- Agent-to-agent calls in your code: the developer reference for A2A requests, errors and delegation.
- A2A call with delegation: the full governance order and error codes.
- Human approvals: add a rule with
action: require_approvalon a skill, for exampleskill:deploy. - Build a governed LangGraph agent to put a real model behind the planner.
- Audit ledger: export the chain as an evidence bundle.