Skip to content

Delegate over A2A

A2A (Agent2Agent) is the Linux Foundation protocol for one agent handing work to another across organizations and frameworks. Every eligible Workprentice agent serves it: an external orchestrator discovers the agent, reads its card, sends it a task, and collects the result — while the work itself runs as an ordinary session with the same log, the same work products, and the same audit trail as anything started from the app.

The surface pins protocol version 1.0 and authenticates with a workspace API key — the same wak_ credential webhooks and MCP clients use.

An agent is served over A2A when the key’s creator can use it — their own agent, or one shared with them at Can use or higher — when it is not paused, and when it is not somebody’s personal agent. Personal agents carry their owner’s identity and private connections, so they are never exposed here. A paused agent (its Automated runs switch is off) drops out of the index and serves no card until it is resumed.

The index lists exactly the eligible agents:

Terminal window
curl https://workprentice.ai/a2a/agents \
-H "Authorization: Bearer wak_..."
{
"agents": [
{
"id": "b3f1…",
"name": "Research",
"cardUrl": "https://workprentice.ai/a2a/b3f1…/.well-known/agent-card.json"
}
]
}

An MCP client can also find cards through list_agents, which returns a card_url for every agent the caller can see. That list is broader than the index: a paused or personal agent appears in it too, and its card URL answers 404.

Each agent’s card sits under its own base URL, so a card resolver pointed at https://workprentice.ai/a2a/<agent-id> finds it at the nested well-known path:

Terminal window
curl https://workprentice.ai/a2a/<agent-id>/.well-known/agent-card.json \
-H "Authorization: Bearer wak_..."

The card is served as application/a2a+json and describes the agent as it is configured right now: its name, a description drawn from the opening of its personality, its icon, and a skills array advertising both its skills and its assignments — assignment entries name their parameters, which is what lets a calling framework phrase a request the agent can actually fill in. capabilities declares streaming true and push notifications false, and securitySchemes describes the HTTP bearer credential.

Cards are auth-gated. A caller needs a workspace key to invoke the agent anyway, and a public card would publish agent names, personas, and skill inventories to anyone who asked. The tradeoff is deliberate: anonymous crawlers cannot index your agents.

One endpoint, JSON-RPC 2.0 over POST /a2a/{agent_id}, and one required header: A2A-Version: 1.0. The spec reads an absent version header as a 0.3 request, so a call without it is refused with VersionNotSupportedError (-32009) rather than dispatched against a method vocabulary it does not share.

Terminal window
curl -X POST https://workprentice.ai/a2a/<agent-id> \
-H "Authorization: Bearer wak_..." \
-H "A2A-Version: 1.0" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "SendMessage",
"params": {
"message": {
"messageId": "req-8f21",
"role": "ROLE_USER",
"content": [
{ "text": "Summarize Q3 pipeline movement and flag any deal that slipped twice." }
]
}
}
}'

The response is the task, abridged here:

{
"jsonrpc": "2.0",
"id": "1",
"result": {
"id": "9c2a…",
"contextId": "51ee…",
"status": {
"state": "TASK_STATE_SUBMITTED",
"timestamp": "2026-07-28T16:04:11Z"
},
"history": [
{
"messageId": "51ee…:12",
"role": "ROLE_USER",
"content": [{ "text": "Summarize Q3 pipeline movement…" }],
"taskId": "9c2a…"
}
]
}
}

Two ids come back. id is the task, which is one turn of work. contextId is the session that turn runs in — the same session a person opens in Workprentice to watch the agent work.

To carry the conversation on, send your next message with the taskId you were given: the agent picks up in the same session with everything it already knows, whether the task was waiting on you or had already completed. A message with no taskId starts a new task in a new session. Canceled, failed, and rejected tasks cannot be continued — there is no window left to reopen, so start a new task instead.

Your messageId is the idempotency key. A caller that retries a delivery with the same id gets the same task back instead of starting a second one, so a flaky network costs nothing.

A message’s content is a list of parts, each carrying exactly one member: text becomes the request itself, data becomes a block of JSON the agent reads as content, and raw carries base64 file bytes that land in the agent’s inputs. url parts are declined with ContentTypeNotSupportedError (-32005) — fetching caller-supplied URLs is a door this surface does not open.

State What it means
TASK_STATE_SUBMITTED Accepted; the agent is starting up.
TASK_STATE_WORKING The agent is on it.
TASK_STATE_INPUT_REQUIRED The agent asked a question and is waiting on an answer.
TASK_STATE_COMPLETED The turn closed with a final answer, and artifacts are attached.
TASK_STATE_FAILED The agent stopped before answering; the status message carries the reason.
TASK_STATE_CANCELED Canceled by CancelTask, or stopped from the app.
TASK_STATE_REJECTED Born terminal — the agent is paused and is not accepting delegated work.

Poll with GetTask, passing taskId (and historyLength when you want less transcript than the whole window):

{ "jsonrpc": "2.0", "id": "2", "method": "GetTask", "params": { "taskId": "9c2a…" } }

CancelTask takes the same parameter and stops the session along with everything it spawned. State is derived fresh on every read, so a GetTask answer is never a stale cache of what the agent was doing a moment ago.

On a completed task, the agent’s answer is the status message, and the fuller exchange sits in history — the messages carrying "role": "ROLE_AGENT". You can pass historyLength: 0 and still get the answer. In the other states the status message says what the state alone cannot: the question behind input-required, the reason behind failed or rejected.

Work products created during the turn come back as artifacts on the completed task. Each artifact carries a data part that points at the thing — its type, session and work-product ids, the authenticated API URL, and a web URL a person can open — followed by either the content inline as a text part (for textual content up to 64 KiB, which covers the usual “the agent wrote a report” case) or a url part for anything binary or larger. The API URL accepts the same Bearer key you already hold. No unauthenticated share links are minted for artifacts.

SendStreamingMessage starts a task and streams it; SubscribeToTask attaches to one already running. Both answer with text/event-stream, where every data: line is a complete JSON-RPC response whose result holds exactly one of task, message, statusUpdate, or artifactUpdate. Lines beginning : ping are keepalives.

The shape of a stream: the task snapshot first, then a statusUpdate per completed agent message, then artifactUpdate frames for the turn’s work products, then a final statusUpdate — after which the stream closes. SubscribeToTask replays nothing, because the opening snapshot already covers whatever you missed.

Fidelity is message granularity by design. The wire carries no token deltas and no thinking or tool-call interiors; an external orchestrator sees completed messages and state transitions, which is what it acts on. The full interior remains visible to people in the session log.

An agent that needs a decision asks for one, and the task moves to input-required with the question as its status message. Answer by sending a message that names the taskId:

{
"jsonrpc": "2.0",
"id": "3",
"method": "SendMessage",
"params": {
"message": {
"messageId": "req-8f22",
"role": "ROLE_USER",
"taskId": "9c2a…",
"content": [{ "text": "Use the FY26 plan numbers." }]
}
}
}

The task returns to working and the window reopens. A person can answer the same question from their inbox, and if both of you answer, nothing breaks: the replies post into the same session, and the agent’s next final message closes the task.

This surface is built to be driven by unattended software, so it carries caps the equivalent REST calls do not:

Limit Default Refusal
A2A-occupied sessions in a workspace at once 10 429 with no Retry-After — nothing ages out, but a later attempt may fit.
Fresh turns started per sliding hour, per workspace 50 429 with Retry-After giving the real deadline.
Concurrent streams held by one key, counted on each server separately 20 429.

An admin can raise the two workspace caps up to the plan’s ceiling under Guardrails → Limits & budgets in organization or workspace settings, and Pro holds fresh turns to 25 an hour; see Limits.

Only a message that starts a turn is counted. Answering a task whose agent is still running — replying to its question, say — counts against neither workspace cap; a follow-up to a task whose agent has gone idle starts a new turn and counts like a fresh one.

Money is bounded elsewhere. There is no separate A2A dollar cap: an A2A turn counts toward a monthly spend limit or a session budget exactly as any other turn does, wherever one is configured.

Those refusals follow the general rule for errors here: anything an HTTP client already knows how to handle stays at the transport level — authentication failures (401, 403), rate refusals (429), and an agent the key may not reach (404, since a caller who cannot delegate to an agent has no business learning it exists). Everything else a caller can act on arrives as a 200 carrying a JSON-RPC error envelope — among them TaskNotFoundError (-32001) for a task id the key cannot see, and TaskNotCancelableError (-32002) for canceling a task that has already finished.

  • Push notifications. The configuration methods answer PushNotificationNotSupportedError (-32003); the card declares pushNotifications: false. Poll GetTask or stream.
  • ListTasks answers UnsupportedOperationError (-32004). Track the task ids you were given.
  • GetExtendedAgentCard answers with the extended-card-not-configured error (-32007), matching the card’s extendedAgentCard: false.
  • url content parts are declined (-32005); send text, data, or raw bytes.
  • Personal agents are not served, and a paused agent rejects delegated work rather than queuing it — pausing an agent is how you take it off this surface without touching any key.

A2A is for frameworks that hand an agent a whole task. If the caller is an MCP client — Claude Code, Claude Desktop, a script, or another Workprentice agent — and you want to decide which tools it sees and what each one asks the agent to do, give the agent an API instead: Give an Agent an API.

For the complete site index, see llms.txt.