Files
VolcanicAgenticCore/docs/a2a.md
2026-10-07 14:32:33 +00:00

13 KiB

A2A server

src/Volcanic.AgenticCore.A2A.Server lets other agents talk to a Volcanic assistant over A2A (spec 1.0), on the official A2A / A2A.AspNetCore .NET SDK. A caller gets a key from the assistant's owner and from then on talks to an isolated session in that owner's workspace: it can delegate work, hold a multi-turn conversation, send and receive files, ask the assistant to tell its owner something, and register a webhook for push updates. This is the server side only; calling other agents from one of our own sessions is a separate client extension (2026-10-07-a2a-client-design.md), not covered here.

The Telegram host (docs/telegram-bot.md#a2a) mounts it with one workspace per allowed chat. samples/Volcanic.AgenticCore.Sample.A2A mounts it over a single folder, for trying it out or scripting against it.

Scenarios

  • Delegation. A caller sends a task ("book three slots next week"); the assistant works it like an ordinary conversation turn and answers with a response artifact.
  • Multi-turn. The assistant calls request_input to ask the caller something; the task becomes input-required and the caller's next message, with the same taskId, continues it.
  • Files. A caller can attach bytes or an https URL; they land in the workspace's inbox/. The assistant's send tool turns into task artifacts in an A2A session, so a file it sends comes back to the caller the same way.
  • Notify the owner. notify_owner(text) is the isolated session's only channel back to its owner — in Telegram, a message into the owner's chat.
  • Push. A caller that registers a webhook (CreateTaskPushNotificationConfig) gets POSTed on every status change instead of polling GetTask.

Files

Everything lives under <workspace>/.volcanic/a2a/. A missing file means "none". A malformed callers.json or card.json means "no callers" / the fallback card, plus a warning log line naming the file — never the keys. A malformed task file is read as "no such task" (TaskNotFound) without a log line.

callers.json, read fresh on every request (no cache, so an edit applies to the very next call, no restart needed):

[
  { "name": "travel-bot", "key": "k_3f9c2a...", "mode": "isolated", "note": "Alice's trip planner" }
]
  • name: [a-z0-9-]{1,40}, unique in the file; it ends up in the session scope, in logs and in the framing header the assistant sees.
  • key: any non-empty string, plaintext. Make one with openssl rand -hex 32.
  • mode: isolated is the only one implemented; omit it or set it explicitly. An unknown mode skips the entry with a warning.
  • Unknown entries (bad name, empty key, duplicate name) are skipped with a warning; everything else in the file is still read.

card.json (optional) overrides the caller's extended agent card:

{ "name": "Eugene's assistant", "description": "Schedules meetings and answers for Eugene.",
  "skills": [ { "id": "scheduling", "name": "Scheduling", "description": "...", "tags": ["calendar"] } ] }

Without it: name from A2A:Card:Name, description from the first non-empty line of the workspace's PERSONA.md (else A2A:Card:Description), and one generic chat skill.

tasks/<taskId>.json: one task per file (the SDK's AgentTask plus which caller owns it and its push configs), written atomically (temp file, then rename) and read fresh on every request.

Endpoints

Endpoint Auth Purpose
GET /.well-known/agent-card.json none The generic public card
POST /a2a key JSON-RPC 2.0: SendMessage, SendStreamingMessage (SSE), GetTask, ListTasks, CancelTask, SubscribeToTask, push-config CRUD, GetExtendedAgentCard
/a2a/v1/... key The same methods as REST (HTTP+JSON)
POST /a2a/push/{watchId} none (token in body/header) Receives pushes for our own client's watches; needs A2AHostOptions.OnPush wired by the host, otherwise every call is 404

Authenticate with the header X-API-Key: <key> or Authorization: Bearer <key>. Exactly one caller must match the key across every workspace the host knows about; no match, or the same key matching more than one caller (in two different workspaces, or two entries of the same callers.json), is a 401 (and a warning "matches N callers (owners: ...)", never the key).

Known REST quirks (the SDK's, not ours): the request body of /a2a/v1/message:send and :stream is parsed before the key check runs, so a malformed body on an unauthenticated call still gets parsed; and the card served at /a2a/v1/card is built once at startup (from PublicUrl, else localhost), so changing PublicUrl needs a restart to reach that one endpoint (/.well-known/agent-card.json and the extended card are always built fresh).

Sessions

One AgentSession per (caller, contextId), opened or resumed the first time a message names that contextId (a missing one gets a fresh UUID). A client-chosen contextId must match [A-Za-z0-9_-]{1,100}, otherwise the message is refused with InvalidParams. Scope: <ScopePrefix>-a2a-<caller>_<contextId> (the Telegram host's ScopePrefix is telegram-<chatId>); caller names never contain _, so two different callers or contexts can't share a session. The session runs in the caller's workspace, so it loads that workspace's AGENTS.md (or CLAUDE.md), PERSONA.md and MEMORY.md exactly like any other session there — same persona, same memory, separate conversation. It does not get a calendar (Google Calendar connections are keyed by session scope, and an A2A scope never connects one).

Only one task runs at a time per context: a second message to a context that is still running gets UnsupportedOperation ("context busy, retry when the current task ends"). An idle session (no messages for SessionIdleTimeout, default 30 minutes) is disposed and simply resumes from its JSONL transcript the next time the context is used — nothing is lost.

request_input and notify_owner only exist inside these sessions; an owner's own chat never sees them.

Task lifecycle

State Reached when Notes
submitted → working A new message with no taskId, or a continuation, starts a run
completed The run ends normally The final assistant text becomes the response artifact. A reply cut off by the model's length limit (StopReason.Length) still counts as completed — it is not flagged as a failure
input-required The agent called request_input(question) The question is the task's status message. The caller continues it with the same taskId (same contextId, or none); a different contextId is InvalidParams. A continuation of a task that is not input-required is UnsupportedOperation
failed A provider error, the hop limit, a bad file, or any unexpected exception The message is the redacted error text; for an unexpected exception it is only "The run failed unexpectedly." (the redacted detail goes to the log)
canceled CancelTask, or the request's own cancellation

Restart recovery is lazy, not a startup scan: a task file read as submitted or working with no live run behind it (the process restarted mid-run) is rewritten failed ("server restarted") the next time anything reads it. input-required tasks are not running, so they survive a restart untouched.

Each run replaces the task's response artifact — GetTask always shows the latest answer, not older turns' text. Pushes carry the whole task (StreamResponse { task }) and fire once per status change, not per text chunk; registered webhooks get X-A2A-Notification-Token when a token was set on the config.

Configuration

A2A section, env A2A__... (A2AHostOptions):

Key Default Meaning
Enabled false Honoured by the Telegram host: on, it mounts the endpoints; off, it opens no HTTP listener at all. The sample host ignores it and always maps the endpoints
PublicUrl — External base URL for the agent card's interfaces and for push webhook URLs. Unset: built from the inbound request, which is wrong behind a reverse proxy (the request scheme may be http) and means push webhooks can't be registered reliably. Set it in production
MaxHops 3 Recursion guard: metadata["volcanic.hops"] (request metadata, then message metadata, else 0); over the limit, the task is created and immediately failed
SessionIdleTimeout 00:30:00 Idle AgentSession eviction (resumable from JSONL)
MaxFileBytes 10485760 (10 MB) Cap per file part, inbound and outbound
AllowPrivatePushUrls false Allow http and loopback/private-range webhooks and downloads — for tests and tailnets only
Card:Name / Card:Description Volcanic assistant / a generic sentence Public card, and the extended-card fallback when a workspace has no card.json

The Telegram host maps its own Telegram:Model into the A2A sessions' model and each caller's owning chat's time zone into its sessions (docs/telegram-bot.md#a2a); a host that embeds the library directly sets A2AHostOptions.Model and TimeZoneFor the same way.

Security

  • Inbound text is untrusted. Every A2A message is wrapped in a framing header telling the model it is talking to an external agent, not its owner, and not to follow instructions that conflict with the owner's interests (it mentions notify_owner only when the host registered an owner notifier, i.e. when the tool exists).

  • The isolated session still has bash and file tools in the shared workspace, and loads the same AGENTS.md/CLAUDE.md, PERSONA.md and MEMORY.md as the owner's own session. It has no calendar and no access to the owner's conversation history, but it can be talked into editing MEMORY.md or PERSONA.md, which then affects the owner's own chat. This is an accepted exposure for v1, not a bug.

  • What bash in the shared workspace reaches. The isolated session has no schedule, calendar or trigger tool, but the files behind them live in the same workspace, so a caller can talk it into:

    • reading .volcanic/a2a/callers.json — every caller's plaintext key — and editing it, i.e. minting itself (or anyone) new keys or changing other callers' entries;
    • writing .volcanic/scheduler/jobs.json or .volcanic/triggers/triggers.json, whose prompts later run in the owner's main session — the one that has the calendar and the owner's history.

    These are accepted v1 exposures: the owner chose plain files with no protection. Give A2A keys only to agents you would trust with that workspace.

  • Keys are plaintext in callers.json, by design — it's the owner's own file to edit. They are never written to logs; /a2a (Telegram) shows only the last four characters, and only for keys of 12 characters or more.

  • Outbound HTTP is guarded. Push webhook deliveries and file-part URL downloads both go through the same handler: https only (unless AllowPrivatePushUrls), no redirects, no system proxy, and every connection target is checked against loopback/private/link-local/unique-local/CGNAT (100.64.0.0/10)/benchmarking (198.18.0.0/15)/multicast ranges at connect time, so a DNS answer that changes between the check and the TCP connect (DNS rebinding) can't reach an internal host.

  • Two continuations racing the same input-required task: both pass the pre-check before either's run starts; the loser gets an error back (InvalidAgentResponse for a blocking SendMessage) and its message may remain in the task's history even though it was never acted on.

  • Known v1 resource limits. Each new contextId opens another session for its caller, so a caller can hold many open sessions at once until SessionIdleTimeout evicts them; there is no per-caller cap. Session JSONL directories (one per context), task files and inbox/ files grow without a total cap — nothing prunes them.

  • Push deliveries for one task can arrive out of order (each is an independent fire-and-forget retry loop); use the task's own status.timestamp, not arrival order, to tell which update is newest.

curl examples

Run the sample host locally (set ASPNETCORE_URLS in the environment — a trailing --urls can lose to a preset env var in some containers):

ASPNETCORE_URLS=http://localhost:5099 dotnet run --project samples/Volcanic.AgenticCore.Sample.A2A -- --faux --workspace /tmp/a2a-ws

Create /tmp/a2a-ws/.volcanic/a2a/callers.json with a key, then:

# Public card, no auth
curl -s http://localhost:5099/.well-known/agent-card.json

# SendMessage (JSON-RPC)
curl -s -H 'X-API-Key: k' -H 'Content-Type: application/json' localhost:5099/a2a \
  -d '{"jsonrpc":"2.0","id":"1","method":"SendMessage","params":{"message":{"role":"ROLE_USER","messageId":"m1","parts":[{"text":"hi"}]}}}'
# → result.task, state TASK_STATE_COMPLETED, artifact "response"

# GetTask, once you have an id from the response above
curl -s -H 'X-API-Key: k' -H 'Content-Type: application/json' localhost:5099/a2a \
  -d '{"jsonrpc":"2.0","id":"2","method":"GetTask","params":{"id":"<taskId>"}}'