A2A agents
For: application developers.
Call an agent or flow through A2A v1.0 when integrating with another team's agent or an A2A client. The client fetches and verifies the signed Agent Card, then sends messages to the agent. Each A2A task is an Orchestrator Zero job.
| Route | What it is |
|---|---|
GET /a2a/<tenant>/<agent>/.well-known/agent-card.json | The Agent Card, generated from the agent's contract and signed |
POST /a2a/<tenant>/<agent> | Its JSON-RPC interface: SendMessage, GetTask, CancelTask |
GET /a2a/<tenant>/jwks.json | The tenant's public key, which signatures name in jku |
The card and the interface take a tenant API key as Authorization: Bearer, like the runtime API; the key set is public. Requests carry A2A-Version: 1.0.
The card
{
"name": "acme-reviewer",
"description": "Reviews a pull request.",
"supportedInterfaces": [{"url": "https://edge.example.com:7443/a2a/acme/acme-reviewer", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}],
"provider": {"organization": "Orchestrator Zero, tenant acme", "url": "https://edge.example.com:7443"},
"version": "9f2c41d07a6b3e18",
"capabilities": {"streaming": false, "pushNotifications": false, "extendedAgentCard": false},
"securitySchemes": {"apiKey": {"httpAuthSecurityScheme": {"scheme": "Bearer", "bearerFormat": "oz0_<id>_<secret>"}}},
"defaultInputModes": ["application/json"], "defaultOutputModes": ["application/json"],
"skills": [{"id": "acme-reviewer", "name": "acme-reviewer", "description": "Reviews a pull request.", "tags": ["github-tools", "agent"]}],
"signatures": [{"protected": "eyJhbGciOiJFUzI1NiIs...", "signature": "..."}]
}
An agent that takes text has the mode text/plain; one with an input or output schema, application/json. version is the tenant's catalog hash, so it changes when a plugin install changes the agents, and the card's ETag with it.
The signature is a JWS (ES256) over the card's canonical form (RFC 8785, without signatures and without empty values), as A2A v1.0 specifies. Each tenant has its own P-256 key, derived from the cluster's master key, so every edge signs alike. Its protected header names the key (kid) and where it is (jku). With the Python SDK:
import httpx, jwt
from a2a.client import A2ACardResolver
from a2a.utils.signing import create_signature_verifier
def key(kid, jku):
keys = httpx.get(jku, verify="ca.crt").json()["keys"]
return next(jwt.PyJWK(k) for k in keys if k["kid"] == kid)
card = await A2ACardResolver(http, "https://edge.example.com:7443/a2a/acme/acme-reviewer").get_agent_card(
signature_verifier=create_signature_verifier(key, ["ES256"])
)
Pin the tenant's key once you trust it, rather than trusting whatever key a jku points to.
Contexts
A task's contextId is its job's conversation, or the job's own ID until it has one. Send the next message with that contextId and no taskId, and it starts the conversation's next turn as a new task in the same context: the agent gets the earlier turns. An agent that takes no part in conversations (one whose input has no message), or a key without the content permission, gets a new task in a context of its own instead, so a client that always sends back the contextId it got keeps working.
A contextId the server never gave, or another agent's, is refused with -32602, and a message whose contextId differs from its task's is refused too (A2A v1.0, section 3.4.1).
A context is its maker's: only the key, operator or person that started it continues it, or a person its chat is shared with. Anyone else who sends its contextId, another key of the same tenant included, gets the same -32602 as for a context the server never gave, so a contextId is no way into someone else's history (A2ABreak's cross-client context injection, arXiv 2609.10871).
The card's URLs (its interface, provider.url and the signature's jku) are the edge API's advertised address: the server's --advertise host and its edge API port, the edge_api address in the operator context (context.json) and in GET /api/info. They never come from the request: whatever Host header a caller sends, a signed card names the cluster's own endpoint, so nobody can obtain a validly signed card that points at their own host. Behind a load balancer or a proxy, set --advertise to the name your A2A clients use.
Messages and tasks
SendMessage with a new message starts a job. Its input is the text of the message's parts for an agent that takes text, or for one with an input schema, its first data part (or a text part that holds JSON). It waits until the job ends or waits for a person, unless configuration.returnImmediately is set, and answers with the task:
| Task state | The job |
|---|---|
TASK_STATE_WORKING | runs |
TASK_STATE_INPUT_REQUIRED | waits for an approval or a question; the status message says what, with a data part that lists each one |
TASK_STATE_COMPLETED | ended; the artifact output holds the answer, as text or as data |
TASK_STATE_FAILED, TASK_STATE_CANCELED | failed, timed out or was canceled; the status message says why, and when a budget, quota or tree limit ended the job, has a data part {"error": {"type", "message", "oz0"}} with its numbers (Spend refusals) |
To answer, send another message with the task's taskId:
- A question: a text part with the answer.
- A tool approval: a data part
{"approve": true}or{"approve": false, "comment": "..."}. Text never approves a call. A tenant API key decides an approval only when the approval's deciders or a notification rule name it (keys that decide); it answers, in its messages to the task, the questions of the tasks it started. - With several waiting, name one:
{"approval": "a-7f2c", "answer": "blue"}.
Messages from the job are ROLE_AGENT. The task, its status messages and its artifact carry the tenant's AI label in metadata["ai.oz0/ai-label"] (none when an admin turned it off): show it to the people who read the answer.
GetTask reads the task, and CancelTask cancels its job. A task of another agent is not found. Streaming, push notifications, ListTasks and the extended card are not offered, and the card says so.
Hardening
- The artifact's hash. A completed task's artifact carries
metadata["ai.oz0/sha256"],sha256:<hex>of itspartsin RFC 8785's canonical form: recompute it from the parts you received to check nothing changed on the way. - One message to a task at a time. A message that answers a task while another message to it is being handled is refused with
-32004; read the task withGetTaskand answer what it still asks. - Waiting ends at the deadline. Each approval or question in an
TASK_STATE_INPUT_REQUIREDstatus names itsdeadline; after it, the job denies it and the task is no longer waiting for it.
Delegation loops
A message may name the job it comes from in metadata["ai.oz0/origin-job"]. One whose agent is already on that job's chain (its job tree, and the jobs earlier messages came from, at most eight hops) is refused with -32004, and so is a chain deeper than eight. Only messages that carry the marker are checked. The platform's own runtime sends it on every call it makes to one of this cluster's own addresses, an outside A2A agent's message or a remote MCP tool's call, and never to anyone else.
Errors
A refusal (a permission, an argument, a spent quota) says what it refuses and why. An error the edge met rather than decided, such as its database or Temporal being away a moment, is answered with fixed words ("the edge cannot answer now; ask again shortly") and its own text stays in the edge's log; MCP and AG-UI clients, and callers of the runtime API over Connect, are answered the same way.
Where the edge knows what you can do instead, such as starting without a job ID of your own while it cannot reach management, it says that in words written for you.
- Revocation. A blocking
SendMessageends at once when its key is revoked or its caller loses the tenant, with apermission_deniederror that names the job. The revocation stops that job too when it ran on that access, as it stops every job of a revoked key or person, and its receipt lists them. - No credentials asked or passed on. The server never answers
TASK_STATE_AUTH_REQUIRED: a tool that needs a credential uses the tenant's own connections, never the caller's. - A scoped key (
oz0 apikey create --scope agent:<name>) reaches only the agents its scopes name: another agent's card is not found, and so are its tasks and contexts, with the answer an ID that does not exist gets.
Push notifications and streaming stay off until each can be offered safely: push needs webhook URLs checked against the tenant's egress rules, verified with the receiver and signed; streaming needs each event checked against what the caller may see, and the stream ended when that changes.
With the JavaScript SDK (@a2a-js/sdk 1.3.0), verify the card's JSON as you fetched it with verifyAgentCardSignature: its resolver turns the card into objects whose canonical form loses securitySchemes, so verifying through client.getAgentCard fails. Which clients were run, and what each did: the interop matrix.
Calling outside agents
This page is about the A2A server every agent is. The other direction, an outside agent that speaks A2A used as an agent of your tenant, runs through the verified plugin a2a-remote: see Outside A2A agents.
Its client holds to the same rules from the other side: it binds an outside agent to its card's origin, checks a card's signature where there is one, polls instead of streaming and gives no push address, sends one message to a task at a time, answers a question only after the job's approval or a person's answer and cancels the task at the approval's deadline, refuses to pass on credentials, checks artifacts' hashes, and keeps a task's context to its own job.