Skip to content

A2A endpoints

Every endpoint the A2A handler exposes. Served on port 7870 by default.

Well-known paths

PathReturns
GET /.well-known/agent-card.jsonThe agent card as JSON
GET /.well-known/agent.jsonAlias for the card; some clients expect this path

Both paths return identical content. Serving both is a spec compatibility hedge — early A2A clients (including older @a2a-js/sdk versions) probed different paths.

Fleet multi-tenancy (/agents/<slug>/…)

A hub instance (one running a fleet of workspace members — ADR 0042) hosts each member as an independently-addressable A2A tenant under a URL prefix, implementing the A2A URL-based routing pattern. The hub reverse-proxies /agents/<slug>/* to the member's own server, so every path in this reference is reachable per-member:

PathReaches
GET /agents/<slug>/.well-known/agent-card.jsonthe member's card
POST /agents/<slug>/a2athe member's JSON-RPC endpoint (its task queue)

<slug> is the member's workspace id (e.g. matt-7689); host targets the hub itself. Routing to a member is pure transport — the hub agent's own reasoning loop is not invoked — so dispatching to a member's tenant path is a direct call, not a delegate_to through the hub agent.

Because a member self-advertises this tenant URL in its card (see supportedInterfaces), a peer that discovers the member's card routes to it correctly with no special client logic — the spec's "read the card, follow its URL" contract. The hub is the single auth boundary (ADR 0089): a caller presents any credential the hub accepts — its A2A_AUTH_TOKEN, an API key, or a per-device token — to /agents/<slug>/a2a. The hub authenticates it, and the proxy then swaps the Authorization header for the internal fleet service token before forwarding to the loopback-bound member. It does not forward the caller's credential verbatim, because each member has its own instance_root and therefore its own device registry and auth.token — a hub-minted device token would be structurally 401'd by every sister. The swap is operator-tier only and preserves the member-public anonymization, so unauthenticated callers still reach only a member's declared public paths.

JSON-RPC methods (POST /a2a)

All methods use JSON-RPC 2.0 envelopes:

json
{
  "jsonrpc": "2.0",
  "id": "<caller-chosen>",
  "method": "<name>",
  "params": { ... }
}

message/send — blocking

Submit a message and wait for the terminal task. Returns the full Task object including artifacts.

json
{
  "method": "message/send",
  "params": {
    "message": {
      "role": "user",
      "parts": [{"text": "summarize https://example.com"}]
    },
    "metadata": {"skill": "summarize_pr"}
  }
}

Result shape:

json
{
  "result": {
    "kind": "task",
    "id": "<task-id>",
    "contextId": "<ctx-id>",
    "status": {"state": "completed"},
    "artifacts": [...],
    "data": {
      "usage": {"input_tokens": 1200, "output_tokens": 340, "total_tokens": 1540},
      "durationMs": 4230
    }
  }
}

The kind: "task" discriminator is required — @a2a-js/sdk routes by it.

message/stream — SSE

Same as message/send but streams frames as the run progresses. One SSE event per frame. Every frame carries a kind discriminator:

kindWhen emitted
taskFirst frame — initial task state
status-updateState transitions, tool-start / tool-end progress, and the input-required pause (with final: true)
artifact-updateStreaming partial outputs

Consumers must check kind before interpreting fields — without it, @a2a-js/sdk's for await loop silently skips frames.

tasks/get

json
{"method": "tasks/get", "params": {"id": "<task-id>"}}

Returns the current state of a task. Use to poll when push notifications aren't wired.

Task records are persisted (instance-scoped a2a-tasks.db, 24h TTL, write-through on create + every terminal transition), so tasks/get and tasks/resubscribe answer with the final state + artifacts even after the in-memory copy is evicted (1h) or the process restarts. The background runner doesn't survive a restart, so any task still non-terminal at boot is marked failed ("interrupted by server restart") rather than left hanging.

tasks/resubscribe

json
{"method": "tasks/resubscribe", "params": {"id": "<task-id>"}}

SSE stream of remaining frames for an in-flight task. Lets a consumer reconnect after a network blip without losing events.

Human-in-the-loop (input-required)

The agent can pause mid-task to ask the operator a question — the spec's input-required flow (ADR 0003). It's driven by the lead-agent ask_human tool, which issues a LangGraph interrupt(); the graph checkpoints at that exact point.

  1. The task transitions to input-required — a status-update whose status.message carries the question, with final: true, closing the SSE cycle. The task is not terminal; it's parked (and persisted, so it survives a restart). Webhook consumers get an immediate (un-throttled) push.
  2. The caller answers by sending a message/send (or message/stream) carrying the same taskId (and contextId) with the reply as a text part. protoAgent resumes the graph via Command(resume=…) from the checkpoint — continuing exactly where ask_human paused — and drives to a terminal state (or another input-required).
json
{"method": "message/stream", "params": {
  "message": {"taskId": "<parked-task-id>", "contextId": "<ctx>",
              "parts": [{"kind": "text", "text": "approved"}]}}}

A message with no taskId (or one that isn't input-required) starts a fresh task as usual. HITL is not advertised as a card extension — the form rides a application/vnd.protolabs.hitl-v1+json DataPart on the INPUT_REQUIRED frame, and a consumer discovers it by reading the part's mimeType. (Earlier revisions of this page and ADR 0014 referred to a hitl-mode-v1 card capability; no such extension is declared or implemented.)

tasks/cancel

json
{"method": "tasks/cancel", "params": {"id": "<task-id>"}}

Transitions the task to canceled if it's still running. No-op on terminal tasks.

agent/getAuthenticatedExtendedCard

json
{"method": "agent/getAuthenticatedExtendedCard", "params": {}}

Returns the agent card to an authenticated caller (the request has already passed the bearer/api-key check). Same shape as the public /.well-known/agent-card.json.

tasks/pushNotificationConfig/{set,get,list,delete}

Register webhooks so a non-streaming consumer is kept updated as work progresses. The agent POSTs to the webhook on every meaningful transition the caller cares about — the initial working, each per-tool progress step, and the terminal state — mirroring what SSE subscribers see. Non-terminal updates are throttled to at most one POST per ~1.5s (carrying the latest state) so a burst of tool events can't storm the webhook; terminal transitions flush immediately. The COMPLETED webhook also carries the terminal artifact.

json
{
  "method": "tasks/pushNotificationConfig/set",
  "params": {
    "taskId": "<task-id>",
    "pushNotificationConfig": {
      "url": "https://consumer/callback/abc",
      "token": "shared-secret"
    }
  }
}

The handler accepts both token shapes the A2A spec permits:

ShapeJSON
Top-level token (what @a2a-js/sdk serializes by default){"url": "...", "token": "..."}
Structured authentication.credentials (RFC-8821){"url": "...", "authentication": {"schemes": ["Bearer"], "credentials": "..."}}

Both produce Authorization: Bearer <token> and the spec-canonical X-A2A-Notification-Token: <token> header on outgoing webhook POSTs, so a strict receiver can validate the notification belongs to a config it created. When both token shapes are present, top-level wins.

Webhook payload: a TaskStatusUpdateEvent (the same envelope as the matching SSE status-update frame); the terminal/COMPLETED POST attaches the full artifact. Delivery retries 3× with exponential backoff (1s/3s/9s), skipping retry on 4xx.

Registered configs are persisted (write-through to an instance-scoped a2a-push.db, 24h TTL) so they survive the task's terminal eviction and a process restart.

REST aliases

Thin REST wrappers are also exposed for non-JSON-RPC clients:

Method + PathEquivalent to
POST /a2a/tasks/:taskId/pushNotificationConfigtasks/pushNotificationConfig/set
GET /a2a/tasks/:taskId/pushNotificationConfigtasks/pushNotificationConfig/list
GET /a2a/tasks/:taskIdtasks/get

Same semantics, same token-shape parsing, same SSRF guarding.

SSRF guard

Outgoing webhook URLs are resolved once and checked against an allowlist before the handler accepts a push config. By default, private IP ranges (RFC1918 + loopback + link-local) are refused.

To permit trusted docker-network hostnames, set:

bash
PUSH_NOTIFICATION_ALLOWED_HOSTS=automaker-server,internal-hooks
PUSH_NOTIFICATION_ALLOWED_CIDRS=10.0.0.0/8,172.16.0.0/12

Hosts in PUSH_NOTIFICATION_ALLOWED_HOSTS bypass the DNS check entirely.

Extension advertisement

The card's capabilities.extensions array declares protocol extensions the agent implements. See Extensions reference for the ones the template ships.

Part of the protoLabs autonomous development studio.