Skip to content

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:send
POST /a2a/{tenant}/{card}/message:stream ▶ (SSE relayed) ──────▶ message:stream

Agent cards registered in the Admin UI

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):

Terminal window
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:

Terminal window
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.

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
Terminal window
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"}]
}
]
}
}

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}}

For long-running agents, message:send may return a task in state submitted or working. Then:

  • GET .../tasks/{task_id} — poll status.state (submittedworkingcompleted / failed / canceled) and collect artifacts
  • GET .../tasks/{task_id}:subscribe — SSE instead of polling
  • POST .../tasks/{task_id}:cancel — request cancellation

The sample brutor-hello-world-a2a (Python/Starlette) is the reference wire shape. A compliant server needs four routes:

server.py — the essential shape (from brutor-hello-world-a2a)
import json, uuid
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, StreamingResponse
from 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"]),
])
Terminal window
uvicorn server:app --host 0.0.0.0 --port 9100

Register 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.

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.