A2A Agents
A2A (Agent2Agent) is the open protocol for agents calling other agents: an agent publishes an agent card describing its skills and endpoint, and peers send it messages and track long-running tasks. Brutor terminates A2A v1.0 at the gateway, so inter-agent traffic gets the same treatment as everything else: authentication, guardrails on a2a_inbound/a2a_outbound surfaces, audit, and cryptographically signed agent cards.
External / internal agent Brutor gateway :8100 Your agent server──────────────────────── ───────────────────── ─────────────────GET /.well-known/agent-card/... ────▶ signed card (Ed25519)POST /a2a/{tenant}/{card}/message:send ▶ auth · guardrails · audit ──────▶ message:sendPOST /a2a/{tenant}/{card}/message:stream ▶ (SSE relayed) ──────▶ message:streamDiscovery: signed agent cards
Section titled “Discovery: signed agent cards”
Each agent registered in a tenant gets a public, unauthenticated discovery endpoint (peers must be able to read the card before they can negotiate auth):
curl -s http://localhost:8100/.well-known/agent-card/default/hello-world-a2a{ "id": "card-01JW...", "tenant_id": "default", "name": "hello-world-a2a", "version": "1.0.0", "trust_tier": "verified", "a2a_version": "1.0", "card": { "name": "Hello World A2A (Brutor sample)", "description": "Demonstrates A2A v1.0 discovery, message:send, and message:stream behind the Brutor gateway proxy.", "version": "1.0.0", "protocolVersion": "1.0", "defaultInputModes": ["text/plain"], "defaultOutputModes": ["text/plain"], "capabilities": {"streaming": true}, "supportedInterfaces": [ {"protocolBinding": "JSONRPC", "url": "http://localhost:8100/a2a/default/hello-world-a2a"} ], "skills": [ {"id": "hello", "name": "hello", "description": "Returns a greeting that echoes the caller's input.", "tags": ["hello", "demo"]} ], "signatures": ["..."] }}The wrapper is Brutor’s registry metadata (stable id, trust_tier); the card field is always a spec-shaped v1.0 Agent Card, and its signatures[] are Ed25519 signatures made with the tenant’s key. Verifiers fetch the tenant’s public JWK Set from:
curl -s http://localhost:8100/.well-known/a2a-jwks/default{"keys": [{"kty": "OKP", "crv": "Ed25519", "kid": "...", "x": "..."}]}A signed card lets a partner prove “this card was published by this tenant and hasn’t been tampered with” — the foundation for cross-org agent trust. Agent cards are created and managed in the Admin UI; each card points at the agent server’s real URL, and the gateway serves and signs the public card.
Calling an agent through the gateway
Section titled “Calling an agent through the gateway”All invocation endpoints live under /a2a/{tenant_id}/{card_name} and require auth (API key or gateway JWT):
| Method + path | Purpose |
|---|---|
POST .../message:send |
Synchronous message — returns a Task |
POST .../message:stream |
Streaming message over SSE |
GET .../tasks |
List tasks |
GET .../tasks/{task_id} |
Get one task |
POST .../tasks/{task_id}:cancel |
Cancel a task |
GET .../tasks/{task_id}:subscribe |
SSE subscription to a task’s updates |
GET .../extendedAgentCard |
Authenticated extended card |
POST/GET/DELETE .../tasks/{task_id}/pushNotificationConfigs[/{config_id}] |
Push-notification config CRUD |
message:send
Section titled “message:send”curl -s -X POST "http://localhost:8100/a2a/default/hello-world-a2a/message:send" \ -H "Authorization: Bearer sk_brutor_api_..." \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "role": "user", "parts": [{"kind": "text", "text": "Hello from my agent"}] } } }'Response — a JSON-RPC envelope whose result is a Task:
{ "jsonrpc": "2.0", "id": 1, "result": { "id": "3f6f0a3e-...", "contextId": "b1a9c2d4-...", "status": {"state": "completed"}, "history": [ {"role": "user", "parts": [{"kind": "text", "text": "Hello from my agent"}]} ], "artifacts": [ { "artifactId": "a7e2...", "name": "greeting", "parts": [{"kind": "text", "text": "Hello from Brutor A2A sample! You said: Hello from my agent"}] } ] }}message:stream (SSE)
Section titled “message:stream (SSE)”Same request body against message:stream; the response is text/event-stream — JSON-RPC frames carrying status-update and artifact-update events, ending with a final: true status:
data: {"jsonrpc":"2.0","id":1,"result":{"kind":"status-update","taskId":"3f6f...","contextId":"b1a9...","status":{"state":"working"}}}
data: {"jsonrpc":"2.0","id":1,"result":{"kind":"artifact-update","taskId":"3f6f...","contextId":"b1a9...","artifact":{"artifactId":"a7e2...","name":"greeting","parts":[{"kind":"text","text":"Hello from Brutor A2A sample! ..."}]}}}
data: {"jsonrpc":"2.0","id":1,"result":{"kind":"status-update","taskId":"3f6f...","contextId":"b1a9...","status":{"state":"completed"},"final":true}}Task lifecycle
Section titled “Task lifecycle”For long-running agents, message:send may return a task in state submitted or working. Then:
GET .../tasks/{task_id}— pollstatus.state(submitted→working→completed/failed/canceled) and collectartifactsGET .../tasks/{task_id}:subscribe— SSE instead of pollingPOST .../tasks/{task_id}:cancel— request cancellation
Building a compliant A2A server
Section titled “Building a compliant A2A server”The sample brutor-hello-world-a2a (Python/Starlette) is the reference wire shape. A compliant server needs four routes:
import json, uuidfrom starlette.applications import Starlettefrom starlette.requests import Requestfrom starlette.responses import JSONResponse, StreamingResponsefrom starlette.routing import Route
PUBLIC_URL = "http://host.docker.internal:9100"
AGENT_CARD = { "name": "My Agent", "description": "Does one thing well.", "version": "1.0.0", "protocolVersion": "1.0", "defaultInputModes": ["text/plain"], "defaultOutputModes": ["text/plain"], "capabilities": {"streaming": True}, "supportedInterfaces": [{"protocolBinding": "JSONRPC", "url": PUBLIC_URL}], "skills": [{"id": "hello", "name": "hello", "description": "Echo greeting", "tags": ["demo"]}],}
def extract_text(message: dict) -> str: for p in message.get("parts") or []: if isinstance(p, dict) and isinstance(p.get("text"), str): return p["text"] return ""
async def message_send(request: Request) -> JSONResponse: body = await request.json() message = (body.get("params") or {}).get("message") or {} reply = f"Hello! You said: {extract_text(message)}" task = { "id": str(uuid.uuid4()), "contextId": str(uuid.uuid4()), "status": {"state": "completed"}, "history": [message], "artifacts": [{ "artifactId": str(uuid.uuid4()), "name": "greeting", "parts": [{"kind": "text", "text": reply}], }], } return JSONResponse({"jsonrpc": "2.0", "id": body.get("id"), "result": task})
async def message_stream(request: Request): body = await request.json() message = (body.get("params") or {}).get("message") or {} reply = f"Hello! You said: {extract_text(message)}" task_id, ctx_id = str(uuid.uuid4()), str(uuid.uuid4())
async def events(): def frame(payload): return f"data: {json.dumps({'jsonrpc': '2.0', 'id': body.get('id'), 'result': payload})}\n\n" yield frame({"kind": "status-update", "taskId": task_id, "contextId": ctx_id, "status": {"state": "working"}}) yield frame({"kind": "artifact-update", "taskId": task_id, "contextId": ctx_id, "artifact": {"artifactId": str(uuid.uuid4()), "name": "greeting", "parts": [{"kind": "text", "text": reply}]}}) yield frame({"kind": "status-update", "taskId": task_id, "contextId": ctx_id, "status": {"state": "completed"}, "final": True})
return StreamingResponse(events(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
app = Starlette(routes=[ Route("/health", lambda r: JSONResponse({"status": "ok"}), methods=["GET"]), Route("/.well-known/agent-card.json", lambda r: JSONResponse(AGENT_CARD), methods=["GET"]), # Register BOTH the literal ':' and percent-encoded '%3A' forms — # some HTTP clients (reqwest in the Rust gateway included) percent-encode # the colon in path segments, and routers treat the two as different paths. Route("/message:send", message_send, methods=["POST"]), Route("/message%3Asend", message_send, methods=["POST"]), Route("/message:stream", message_stream, methods=["POST"]), Route("/message%3Astream", message_stream, methods=["POST"]),])uvicorn server:app --host 0.0.0.0 --port 9100Register the server in Brutor by creating an agent card in the Admin UI whose interface URL points at it (http://host.docker.internal:9100 when the gateway runs in Docker and your agent runs on the host). The gateway then serves signed discovery and proxies invocations to your server. Success moment: curl the message:send example above and watch the exchange appear in Mission Control with the calling principal attached.
Calling remote agents (outbound)
Section titled “Calling remote agents (outbound)”Your agents can also consume remote A2A agents through the gateway instead of calling them directly — outbound calls pass a2a_outbound guardrails and are audited with the calling principal. Register a card whose interface URL is the remote endpoint and invoke it via the gateway paths above. A legacy v0.3 dialect also exists at POST /v1/proxy/a2a/outbound / /inbound with an HMAC-signed x-brutor-delegation-* header chain for carrying the original principal across agent hops — see the MCP & A2A API reference.
Related
Section titled “Related”- Build an agent — the agent logic behind (or in front of) these endpoints
- Agent identity & policies — the card advertises what an agent exposes; the identity is who it is and what it may do
- Guardrails — the
a2a_inbound/a2a_outboundsurfaces - MCP & A2A API reference
