Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A2A agents

Rustybin hosts seven mock Agent2Agent (A2A) agents for agent gateway demos: discovery through Agent Cards, routing per agent, authentication, streaming, long-running tasks, multi-turn conversations and push notifications. Each agent speaks both protocol generations over both bindings, and the official a2a-sdk Python client is driven against every agent in conformance/a2a/.

Agents

AgentPathBehaviour
Echo (default)/a2a/echo (also /a2a)Echoes every part (text, data, files) as a completed task with an echo artifact; replies with a direct message instead when the text starts with msg: or metadata.reply is "message"
Weather/a2a/weatherA deterministic fake three-day forecast as a JSON data artifact plus a text summary ("weather in Paris" or data {"city": "Paris"})
Travel planner/a2a/travel-plannerLong-running: SUBMITTED, WORKING with status updates, artifact chunks (append, lastChunk), then COMPLETED with Markdown, JSON, a file URL and inline file bytes; metadata.stepDelayMs (0 to 5000) sets the pace
Approval/a2a/approvalMulti-turn: stops in INPUT_REQUIRED and continues when a follow-up message with the same taskId says "approve" or "deny"
Flaky/a2a/flakyEvery task ends FAILED unless the text contains "succeed"
Secure/a2a/secureNeeds a bearer token from the built-in identity provider: without one the task stops in AUTH_REQUIRED; resend with the same taskId and Authorization to continue; has an authenticated extended card
Reject/a2a/rejectEvery task ends REJECTED

Protocol versions and bindings

v1.0v0.3
Selected byA2A-Version: 1.0 headerno A2A-Version header (or 0.3)
JSON-RPC (POST /a2a/{agent})SendMessage, SendStreamingMessage, GetTask, ListTasks, CancelTask, SubscribeToTask, Create/Get/List/DeleteTaskPushNotificationConfig, GetExtendedAgentCardmessage/send, message/stream, tasks/get, tasks/cancel, tasks/resubscribe, tasks/pushNotificationConfig/set/get/list/delete, agent/getAuthenticatedExtendedCard
HTTP+JSON (/a2a/{agent}/v1/...)ProtoJSON of the v1.0 proto (TASK_STATE_COMPLETED, ROLE_USER), errors as google.rpc.StatusProtoJSON of the v0.3 proto

The HTTP+JSON paths, below /a2a/{agent}/v1: POST message:send, POST message:stream (SSE), GET tasks, GET tasks/{id}, POST tasks/{id}:cancel, POST tasks/{id}:subscribe (SSE), tasks/{id}/pushNotificationConfigs[/{configId}], GET extendedAgentCard and GET card.

A method of one generation with the other generation's version is refused with VersionNotSupportedError (-32009), like the reference SDK does.

Discovery

RouteCard
GET /.well-known/agent-card.jsonThe default (echo) agent, v1.0 fields plus the v0.3 fields so both client generations can read it; an extension lists every demo agent
GET /.well-known/agent.jsonLegacy v0.3 card of the default agent (url + preferredTransport)
GET /a2aA directory of the agents (card URLs, JSON-RPC and REST endpoints, requiresAuth)
GET /a2a/{agent}, GET /a2a/{agent}/.well-known/agent-card.jsonThe agent's card
GET /a2a/{agent}/.well-known/agent.jsonThe agent's legacy v0.3 card

Card URLs are derived from the request: https on the HTTPS listener, the Host header, and Forwarded / X-Forwarded-Proto / X-Forwarded-Host / X-Forwarded-Port with RUSTYBIN_TRUST_FORWARD=true, so cards fetched through a gateway point at the gateway. Cards carry Cache-Control and ETag.

GET {{base_url}}/a2a
HTTP 200
[Asserts]
jsonpath "$.agents[*].id" contains "travel-planner"
jsonpath "$.agents[?(@.id == 'secure')].requiresAuth" nth 0 == true
GET {{base_url}}/.well-known/agent-card.json
HTTP 200
[Asserts]
jsonpath "$.name" == "Echo Agent"
jsonpath "$.supportedInterfaces[*].protocolBinding" contains "JSONRPC"
jsonpath "$.supportedInterfaces[*].protocolBinding" contains "HTTP+JSON"

GET {{base_url}}/a2a/weather/.well-known/agent-card.json
HTTP 200
[Asserts]
jsonpath "$.name" == "Weather Agent"
jsonpath "$.skills[0].id" == "forecast"

# Legacy v0.3 card: url + preferredTransport.
GET {{base_url}}/a2a/weather/.well-known/agent.json
HTTP 200
[Asserts]
jsonpath "$.url" == "{{base_url}}/a2a/weather"
jsonpath "$.preferredTransport" == "JSONRPC"

Sending messages

POST {{base_url}}/a2a/echo
A2A-Version: 1.0
Content-Type: application/json
{
  "jsonrpc": "2.0", "id": 1, "method": "SendMessage",
  "params": {"message": {"messageId": "m-1", "role": "ROLE_USER", "parts": [{"text": "hello agent"}]}}
}
HTTP 200
[Captures]
echo_task: jsonpath "$.result.task.id"
[Asserts]
jsonpath "$.result.task.status.state" == "TASK_STATE_COMPLETED"
jsonpath "$.result.task.artifacts[0].parts[0].text" == "hello agent"

POST {{base_url}}/a2a/echo
A2A-Version: 1.0
Content-Type: application/json
{"jsonrpc": "2.0", "id": 2, "method": "GetTask", "params": {"id": "{{echo_task}}", "historyLength": 1}}
HTTP 200
[Asserts]
jsonpath "$.result.id" == "{{echo_task}}"
jsonpath "$.result.history" count == 1
# v0.3 clients send no A2A-Version header and use the old method names and shapes.
POST {{base_url}}/a2a/echo
Content-Type: application/json
{
  "jsonrpc": "2.0", "id": 1, "method": "message/send",
  "params": {"message": {"kind": "message", "messageId": "m-2", "role": "user", "parts": [{"kind": "text", "text": "hello agent"}]}}
}
HTTP 200
[Asserts]
jsonpath "$.result.kind" == "task"
jsonpath "$.result.status.state" == "completed"
POST {{base_url}}/a2a/echo
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {"message": {"messageId": "m-3", "role": "ROLE_USER", "parts": [{"text": "x"}]}}}
HTTP 200
[Asserts]
jsonpath "$.error.code" == -32009
jsonpath "$.error.data[0].reason" == "VERSION_NOT_SUPPORTED"
POST {{base_url}}/a2a/echo
A2A-Version: 1.0
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {"message": {"messageId": "m-4", "role": "ROLE_USER", "parts": [{"text": "msg: no task please"}]}}}
HTTP 200
[Asserts]
jsonpath "$.result.message.role" == "ROLE_AGENT"
jsonpath "$.result.task" not exists
POST {{base_url}}/a2a/weather/v1/message:send
A2A-Version: 1.0
Content-Type: application/json
{"message": {"messageId": "w-1", "role": "ROLE_USER", "parts": [{"text": "What is the weather in Paris?"}]}}
HTTP 200
[Captures]
weather_task: jsonpath "$.task.id"
[Asserts]
jsonpath "$.task.status.state" == "TASK_STATE_COMPLETED"
jsonpath "$.task.artifacts[0].parts[0].data.city" == "Paris"
jsonpath "$.task.artifacts[0].parts[0].mediaType" == "application/json"

GET {{base_url}}/a2a/weather/v1/tasks/{{weather_task}}
A2A-Version: 1.0
HTTP 200
[Asserts]
jsonpath "$.id" == "{{weather_task}}"

GET {{base_url}}/a2a/weather/v1/tasks/no-such-task
A2A-Version: 1.0
HTTP 404
[Asserts]
jsonpath "$.error.details[0].reason" == "TASK_NOT_FOUND"

Task lifecycle

Tasks move through SUBMITTED, WORKING and then a terminal state (COMPLETED, FAILED, CANCELED, REJECTED) or an interrupted state (INPUT_REQUIRED, AUTH_REQUIRED) that a follow-up message with the same taskId resumes. A blocking send waits for the task to settle (at most 100 seconds); returnImmediately: true in the send configuration returns the task right away. Running tasks can be canceled; canceling a finished task is TaskNotCancelableError, an unknown task TaskNotFoundError.

Tasks are scoped to the session that created them (X-Rustybin-Session, else the client IP) and to their agent, so clients never see each other's tasks. The store is bounded (5000 tasks, 1000 per session, idle TTL one hour; 2000, 50 and 15 minutes in public mode).

POST {{base_url}}/a2a/approval/v1/message:send
A2A-Version: 1.0
Content-Type: application/json
{"message": {"messageId": "a-1", "role": "ROLE_USER", "parts": [{"text": "Approve my 120 dollar taxi expense"}]}}
HTTP 200
[Captures]
approval_task: jsonpath "$.task.id"
approval_ctx: jsonpath "$.task.contextId"
[Asserts]
jsonpath "$.task.status.state" == "TASK_STATE_INPUT_REQUIRED"

POST {{base_url}}/a2a/approval/v1/message:send
A2A-Version: 1.0
Content-Type: application/json
{"message": {"messageId": "a-2", "role": "ROLE_USER", "taskId": "{{approval_task}}", "contextId": "{{approval_ctx}}", "parts": [{"text": "approve"}]}}
HTTP 200
[Asserts]
jsonpath "$.task.status.state" == "TASK_STATE_COMPLETED"
jsonpath "$.task.artifacts[0].parts[0].data.approved" == true
POST {{base_url}}/a2a/reject
A2A-Version: 1.0
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {"message": {"messageId": "r-1", "role": "ROLE_USER", "parts": [{"text": "book a flight"}]}}}
HTTP 200
[Asserts]
jsonpath "$.result.task.status.state" == "TASK_STATE_REJECTED"

POST {{base_url}}/a2a/flaky
A2A-Version: 1.0
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {"message": {"messageId": "f-1", "role": "ROLE_USER", "parts": [{"text": "do something"}]}}}
HTTP 200
[Asserts]
jsonpath "$.result.task.status.state" == "TASK_STATE_FAILED"

Streaming (SendStreamingMessage, message:stream) sends the task first, then status and artifact updates, and ends when the task reaches a terminal or interrupted state (or after 10 minutes):

# The stream ends when the task reaches a terminal state.
POST {{base_url}}/a2a/travel-planner/v1/message:stream
A2A-Version: 1.0
Content-Type: application/json
{"message": {"messageId": "t-1", "role": "ROLE_USER", "parts": [{"text": "Plan a 3 day trip to Lisbon"}], "metadata": {"stepDelayMs": 0}}}
HTTP 200
[Asserts]
header "Content-Type" contains "text/event-stream"
body contains "TASK_STATE_SUBMITTED"
body contains "\"artifactUpdate\""
body contains "TASK_STATE_COMPLETED"

Authentication

The secure agent accepts RS256 access tokens from the built-in identity provider; its card declares the scheme and its extended card (GetExtendedAgentCard, GET .../v1/extendedAgentCard) lists an extra skill and requires the token.

POST {{base_url}}/a2a/secure/v1/message:send
A2A-Version: 1.0
Content-Type: application/json
{"message": {"messageId": "s-1", "role": "ROLE_USER", "parts": [{"text": "who am I?"}]}}
HTTP 200
[Captures]
secure_task: jsonpath "$.task.id"
[Asserts]
jsonpath "$.task.status.state" == "TASK_STATE_AUTH_REQUIRED"

POST {{base_url}}/oauth/token
[BasicAuth]
rustybin: secret
[Form]
grant_type: client_credentials
HTTP 200
[Captures]
agent_token: jsonpath "$.access_token"

POST {{base_url}}/a2a/secure/v1/message:send
A2A-Version: 1.0
Authorization: Bearer {{agent_token}}
Content-Type: application/json
{"message": {"messageId": "s-2", "role": "ROLE_USER", "taskId": "{{secure_task}}", "parts": [{"text": "who am I?"}]}}
HTTP 200
[Asserts]
jsonpath "$.task.status.state" == "TASK_STATE_COMPLETED"

Push notifications

A push notification config (in the send configuration or created separately) makes Rustybin POST every task update to a URL, with Content-Type: application/a2a+json, X-A2A-Notification-Token when a token was set and an Authorization header built from the config's authentication.

Because push URLs come from clients, they are an SSRF risk, so they are checked when the config is created:

  • this server's own sink, /a2a/webhook-sink/{id} (a bare path or a URL on the same host the request used), is always accepted and delivered in-process;
  • hosts listed in RUSTYBIN_A2A_PUSH_ALLOWLIST (host or host:port) are delivered over HTTP(S);
  • RUSTYBIN_A2A_PUSH_ALLOW_ALL=true accepts any http(s) URL, including loopback and private addresses, except in public mode;
  • anything else is rejected with an invalid params error.

The built-in sink records what arrives: GET /a2a/webhook-sink/{id} lists the notifications (headers and body), DELETE clears them, and anyone can POST to it (ids are 1 to 64 characters of A-Z a-z 0-9 . _ -). It is bounded (500 sinks, 100 in public mode, 50 notifications each, one hour).

POST {{base_url}}/a2a/travel-planner/v1/message:send
A2A-Version: 1.0
Content-Type: application/json
{
  "message": {"messageId": "p-1", "role": "ROLE_USER", "parts": [{"text": "Weekend to Kyoto"}], "metadata": {"stepDelayMs": 0}},
  "configuration": {"taskPushNotificationConfig": {"url": "/a2a/webhook-sink/docs-sink", "token": "docs-token"}}
}
HTTP 200
[Asserts]
jsonpath "$.task.status.state" == "TASK_STATE_COMPLETED"

# Notifications are delivered asynchronously: poll the built-in sink.
GET {{base_url}}/a2a/webhook-sink/docs-sink
[Options]
retry: 20
retry-interval: 100
HTTP 200
[Asserts]
jsonpath "$.notifications[0].headers['x-a2a-notification-token']" == "docs-token"
jsonpath "$.notifications[*].body.statusUpdate.status.state" contains "TASK_STATE_COMPLETED"

DELETE {{base_url}}/a2a/webhook-sink/docs-sink
HTTP 200
POST {{base_url}}/a2a/travel-planner/v1/message:send
A2A-Version: 1.0
Content-Type: application/json
{"message": {"messageId": "p-2", "role": "ROLE_USER", "parts": [{"text": "trip"}]}, "configuration": {"taskPushNotificationConfig": {"url": "http://169.254.169.254/latest/meta-data"}}}
HTTP 400
[Asserts]
jsonpath "$.error.status" == "INVALID_ARGUMENT"
jsonpath "$.error.message" contains "RUSTYBIN_A2A_PUSH_ALLOWLIST"

Conformance

PYTHON=/path/to/venv/bin/python conformance/a2a/run.sh

checks every agent with the official SDK client over v1.0 and v0.3, JSON-RPC and HTTP+JSON: card resolution, blocking and streaming sends, get, cancel, artifacts, multi-turn, auth-required, the extended card and push notification CRUD and delivery.