MAQPNADocs

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 maqpna CLI (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:cleanup on a2a/worker like 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 in ext.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#