12 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
responseartifact. - Multi-turn. The assistant calls
request_inputto ask the caller something; the task becomesinput-requiredand the caller's next message, with the sametaskId, continues it. - Files. A caller can attach bytes or an
httpsURL; they land in the workspace'sinbox/. The assistant'ssendtool 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 pollingGetTask.
Files
Everything lives under <workspace>/.volcanic/a2a/. A missing file means "none"; a malformed one means "none" plus
a warning log line naming the file — never the keys.
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 withopenssl rand -hex 32.mode:isolatedis 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 callers in two different
workspaces, is a 401 (and a warning naming the 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 |
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 |
Mount the endpoints. Off: the host opens no HTTP listener at all |
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.
- The isolated session still has
bashand file tools in the shared workspace, and loads the sameAGENTS.md/CLAUDE.md,PERSONA.mdandMEMORY.mdas the owner's own session. It has no calendar and no access to the owner's conversation history, but it can be talked into editingMEMORY.mdorPERSONA.md, which then affects the owner's own chat. This is an accepted exposure for v1, not a bug. - 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:
httpsonly (unlessAllowPrivatePushUrls), 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-requiredtask: both pass the pre-check before either's run starts; the loser gets an error back (InvalidAgentResponsefor a blockingSendMessage) and its message may remain in the task's history even though it was never acted on. - 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>"}}'