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.
Discovery
Section titled “Discovery”An agent is served over A2A when it is enabled, when it is shared with the key’s creator at the use tier or higher, 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.
Two ways to find the eligible ones. From an
MCP client, list_agents returns each agent
with its card_url. Or ask the index directly:
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" } ]}The agent card
Section titled “The agent card”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:
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.
Starting a task
Section titled “Starting a task”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.
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.
Task lifecycle
Section titled “Task lifecycle”| 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.
What comes back
Section titled “What comes back”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.
Streaming
Section titled “Streaming”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.
Answering a question
Section titled “Answering a question”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.
Limits
Section titled “Limits”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 | 20 | 429. |
The two workspace caps are tunable per organization or per workspace, so a team driving the surface hard can be given more room without loosening it everywhere. Continuing a task whose agent is already running does not count against the hourly figure — only turns that start fresh work do.
Money is bounded elsewhere: an A2A turn draws on the same monthly spend cap and credit balance as any other turn, and on a session budget wherever one is set, so there is no separate A2A dollar cap to configure.
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.
Not supported
Section titled “Not supported”- Push notifications. The configuration methods answer
PushNotificationNotSupportedError(-32003); the card declarespushNotifications: false. PollGetTaskor stream. ListTasksanswersUnsupportedOperationError(-32004). Track the task ids you were given.GetExtendedAgentCardanswers with the extended-card-not-configured error (-32007), matching the card’sextendedAgentCard: false.urlcontent parts are declined (-32005); sendtext,data, orrawbytes.- 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.
For the complete site index, see llms.txt.