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
| Agent | Path | Behaviour |
|---|---|---|
| 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/weather | A 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-planner | Long-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/approval | Multi-turn: stops in INPUT_REQUIRED and continues when a follow-up message with the same taskId says "approve" or "deny" |
| Flaky | /a2a/flaky | Every task ends FAILED unless the text contains "succeed" |
| Secure | /a2a/secure | Needs 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/reject | Every task ends REJECTED |
Protocol versions and bindings
| v1.0 | v0.3 | |
|---|---|---|
| Selected by | A2A-Version: 1.0 header | no A2A-Version header (or 0.3) |
JSON-RPC (POST /a2a/{agent}) | SendMessage, SendStreamingMessage, GetTask, ListTasks, CancelTask, SubscribeToTask, Create/Get/List/DeleteTaskPushNotificationConfig, GetExtendedAgentCard | message/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.Status | ProtoJSON 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
| Route | Card |
|---|---|
GET /.well-known/agent-card.json | The 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.json | Legacy v0.3 card of the default agent (url + preferredTransport) |
GET /a2a | A directory of the agents (card URLs, JSON-RPC and REST endpoints, requiresAuth) |
GET /a2a/{agent}, GET /a2a/{agent}/.well-known/agent-card.json | The agent's card |
GET /a2a/{agent}/.well-known/agent.json | The 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(hostorhost:port) are delivered over HTTP(S); RUSTYBIN_A2A_PUSH_ALLOW_ALL=trueaccepts 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.