28 KiB
A2A
Two independent halves, each with its own switch:
- Server (
src/Volcanic.AgenticCore.A2A.Server,A2A__Enabled): other agents call a Volcanic assistant. The sections from Scenarios to curl examples describe it. - Client (
extensions/Volcanic.AgenticCore.Extensions.A2A,A2AClient__Enabled): the assistant calls other agents through itsa2atool. See Client.
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. Calling other agents from one of our own sessions
is the client.
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 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 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.
The client keeps its own files in the same directory: agents.json (the agents this workspace's assistant
may call, with their keys; see agents.json) and pending.json (its watches of remote tasks; see
Watches).
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_owneronly when the host registered an owner notifier, i.e. when the tool exists). -
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. -
What
bashin the shared workspace reaches. The isolated session has noschedule,calendarortriggertool, 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.jsonor.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; - with the client on, forging
.volcanic/a2a/pending.json: a record with anundeliveredtext runs as a prompt in the owner's chat on the next restart or session start; - editing
.volcanic/a2a/agents.jsonto point an agent name somewhere else, so the owner's nextsendto that name — message, attachments and the configured key — goes there (and reading the keys in it).
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.
- reading
-
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. -
Known v1 resource limits. Each new
contextIdopens another session for its caller, so a caller can hold many open sessions at once untilSessionIdleTimeoutevicts them; there is no per-caller cap. Session JSONL directories (one per context), task files andinbox/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>"}}'
Client
extensions/Volcanic.AgenticCore.Extensions.A2A lets the owner's assistant call other A2A agents: hand a subtask to
a specialist, hold a multi-turn exchange with it (including answering its questions, after asking the user if need
be), and get the results of long tasks back later without anyone asking. It talks to any A2A 1.0 agent over the
official A2A client SDK, and to another Volcanic assistant's server in particular
(example).
Enabling the client
A2AClient__Enabled=true (section A2AClient; Docker: A2A_CLIENT_ENABLED=true). It is independent of the
server's A2A__Enabled: the client works with the server off, it just can't receive pushes (see Watches).
Off (the default), no session gets the tool, no watches are restored, and /a2a says so.
Only the owner's own chats get the a2a tool: the Telegram host adds A2AClientExtension to each allowed chat's
session (target = the chat id), never host-wide. So the isolated sessions the server opens for inbound A2A callers
(untrusted) can't call other agents through the tool (their bash can still read the keys in agents.json; see
Client security). The startup log shows
A2A client: on, agents in each chat's .volcanic/a2a/agents.json, watches subscribe or poll (or push at <url>).
agents.json
<workspace>/.volcanic/a2a/agents.json lists the agents this chat's assistant may call. It is a plain file the user
and the agent edit (the agent with its file tools, when asked to "add the travel bot"), read on every call: an
edit applies to the next call, no restart.
[
{ "name": "travel-bot", "url": "https://travel.example/", "key": "k_3f9c2a...", "description": "Plans trips (optional override)" }
]
name:[a-z0-9-]{1,40}, unique; what the model passes asagent.url: absolutehttp(s)base URL; the card is fetched from<url>/.well-known/agent-card.json. A URL ending in.jsonis taken as the card URL itself.key: optional. Sent asAuthorization: Bearer <key>when every security scheme on the card is HTTP bearer, otherwise in the header named by the card's API-key scheme (X-API-Keywhen it names none). With a key, and a card that offers one, the extended card is fetched and used; if that fails the public card is kept.description: optional, replaces the card's description in the tool's listing.- A missing file means no agents. Malformed JSON, or not an array, means no agents and the tool says the file couldn't
be read. A bad entry (bad name or url, duplicate name) is skipped and reported as
skipped: ...; the others still work.
Keys never appear in tool results, errors or logs: exception texts are scrubbed of the agent's key, and /a2a shows
only the last four characters of keys of 12 characters or more.
The a2a tool
One tool, a2a, with action and, depending on it, agent, message, contextId, taskId, files.
| Action | Does |
|---|---|
agents |
For every entry, fetches the card (CardTimeoutSeconds) and lists name, description, card name, streaming/push capabilities and skills (id, name, description, up to three examples), or unreachable (<reason>) |
send |
Sends message and/or files to agent; contextId continues an earlier conversation with it, taskId answers its question on that task |
status |
GetTask for taskId: same result shape as send |
cancel |
Stops this chat's watch of taskId, then CancelTask |
send calls SendMessage with returnImmediately: true and metadata["volcanic.hops"] (see Hops), then
waits up to WaitSeconds (60) for the task to settle: it subscribes when the card advertises streaming (a stream that
fails or ends early falls back to polling) and otherwise polls GetTask every second. The send itself is never
streamed, because a dropped streaming request cancels the remote run on servers that tie the run to the request (ours
does). Then:
- Settled (
completed,failed,canceled,rejected, or waiting for us:input-required,auth-required): the result isAgent "travel-bot": task <taskId> (context <contextId>) is completed., then the answer, then optional linesFiles: ...(saved files),Files skipped (over N bytes): ...andLinks: .... The answer is theresponseartifact, else every text artifact, else the status message. Forinput-required/auth-requiredthe question (status message) comes first, followed by the hint to reply withaction=send, the agent andtaskId=<id>; the model asks the user first when it doesn't know the answer. - Still running: a watch takes over and the result says so (
It is still running: the result will be delivered to this conversation when it is ready.). The same happens when the run is aborted (/stop) or the remote stops answering whilesendwaits: once the remote task exists it is never left unwatched. - A plain message reply (no task):
Agent "travel-bot" replied (context <contextId>):and its text.
Files in: files are workspace paths (relative to the workspace). Each is confined to the workspace lexically and
after resolving every symlink; directories and missing files are refused, and all attachments together may be at most
MaxFileBytes. The media type comes from the extension (.png, .jpg, .pdf, .txt, .md, .json, .csv,
else application/octet-stream).
Files out: file parts with inline bytes in a settled task's artifacts are saved to <workspace>/inbox/a2a/<agent>/
(name from the part, sanitised; -2, -3, ... on a clash) and listed under Files:; a part over MaxFileBytes is
skipped and listed. URL parts are listed under Links: and not downloaded.
cancel stops the watch first, so a canceled outcome isn't delivered again later. If the remote refuses (typically
because the task already finished), the result says so and carries the task's outcome. If the remote can't be reached
or doesn't answer in time, the watch is re-armed and the error ends with The task is still watched.
Errors are error tool results with a readable message: unknown agent (with the configured names), rejected the key (401), Could not reach agent ..., Agent ... refused: ... (<code>), did not answer in time. Every single remote
call is bounded by 60 seconds; an agent that ignores returnImmediately and needs longer than that to answer
SendMessage gets did not answer in time, and since its task id never arrived, nothing watches that task.
Watches
A watch follows one remote task of one chat until it settles, then delivers the result into that chat. All watches live
in one process-wide A2AWatchRegistry (one HTTP handler, built on TimeProvider). A chat watches a task at most once:
handing over a task that is already watched keeps the existing watch.
How it learns about updates, chosen when the watch starts:
- push: only when the server is on with
A2A__PublicUrlset (the push receiver lives there) and the remote card advertises push notifications. The watch registers a push config with id = the watch id, URL<PublicUrl>/a2a/push/<watchId>and a random 32-byte token. A push only means "check now": the receiver checks the token (constant time; unknown watch or bad token → 404) and wakes the watch, which reads the task withGetTask; the body is never trusted. A safetyGetTaskruns every 10 minutes. If the remote refuses the push config, the watch subscribes or polls instead. - subscribe: the card advertises streaming. A stream that drops or ends before the task settles is reopened after 1 s, 5 s, then 15 s; after 3 failures in a row the watch polls instead. A stream is also reopened after a day.
- poll:
GetTaskafter 15 s, 30 s, 1 min, 2 min, then every 5 min.
Each check reads agents.json again: an agent removed from it ends the watch with a (stopped) notice; a malformed
file is treated as a passing failure (the user may be editing it) and the watch keeps going. A watch keeps its
connection (card and client) between checks and reconnects after a failure or when the agent's entry changes. After
WatchTimeout (1 day by default) from its start the watch gives up with a (no result) notice suggesting
action=status. On a settled task it saves files like send does and, for push watches, deletes the push config
(best effort).
The delivered text is a follow-up prompt:
[A2A result from "travel-bot", task 5c1e... (completed)]
Context: 7f20...
<the agent's answer>
Files: inbox/a2a/travel-bot/itinerary.pdf
[A2A question from ...] for input-required/auth-required, with the hint to answer with send and the taskId.
In Telegram it runs in the chat's session like a scheduled prompt: with the local-time header, as a follow-up (queued
behind a running request, never steering it), never through the chat's inbox, and the session is opened if it isn't
yet. Other hosts get the default delivery into the target's live session; with no live session the result is kept.
pending.json. Every watch is a record in <workspace>/.volcanic/a2a/pending.json:
[ { "watchId": "...", "target": "111111111", "agent": "travel-bot", "taskId": "...", "contextId": "...",
"pushToken": null, "startedAt": "2026-10-08T12:00:00+00:00", "source": "poll", "undelivered": null } ]
A finished watch first stores its text as undelivered, then delivers it, then removes the record. A result that
couldn't be delivered (no live session, a chat no longer allowed, a failed session start) stays there and goes out on
the chat's next session start (a second after it) or the next restart. Delivery is at least once: a crash between
delivering and removing the record, or a rare race between a finishing watch and a new send of the same task, can
deliver a result twice.
Restarts. At startup, in the background (startup never waits for it), the host re-arms the watches in every
allowed chat's pending.json and then delivers the results kept there; a push watch re-armed while no push URL is
configured subscribes instead. On shutdown the watches stop and their records stay for the next start.
Hops
Every outgoing message carries metadata["volcanic.hops"] = the session's inbound hop count + 1. The count comes from
the a2a.inbound event the server's per-session extension emits before each inbound prompt; a session with no inbound
A2A counts 0. A Volcanic server refuses a task over its MaxHops (3), which stops two assistants delegating to each
other forever. In Telegram only the owner's chats have the tool, so their messages always carry 1 and the listener is
inactive: the remote side's MaxHops (or whatever guard a non-Volcanic remote has) is the only thing that bounds a
chain of agents delegating onwards. The listener matters for hosts that add the extension to
sessions that also receive A2A.
Client configuration
A2AClient section, env A2AClient__... (A2AClientSettings):
| Key | Default | Meaning |
|---|---|---|
Enabled |
false |
Adds the a2a tool to the owner's chats and restores watches. Independent of A2A:Enabled |
WaitSeconds |
60 |
How long send waits for a result before a watch takes over. 0 hands over at once |
WatchTimeout |
1.00:00:00 |
When a watch gives up, counted from its start |
CardTimeoutSeconds |
10 |
Card (and extended card) fetch timeout per agent |
MaxFileBytes |
10485760 (10 MB) |
Cap on the files attached to one send together, and on each received file part |
The push base URL isn't a client setting: the Telegram host sets it from A2A:PublicUrl while the server is on and
ignores any configured value otherwise.
Client security
agents.jsonkeys are plaintext in the workspace, likecallers.json: anything that reads the workspace (the agent'sbash, an inbound A2A caller's isolated session, a backup) can read them.- An inbound A2A caller's isolated session can tamper with the client's files through
bashin the shared workspace: forgepending.json(anundeliveredtext runs as a prompt in the owner's chat on restart or session start) or redirect an agent name inagents.json(the owner's nextsend, its attachments and the key go to the new URL). Accepted v1 exposures, likejobs.json(see Security). - The agent chooses where data goes. It can edit
agents.jsonlike any workspace file, so it can add an agent with any URL (including internal addresses; the client's HTTP has no private-range guard) and send it workspace files. That is the same trust it already has throughbash(curl), not a new boundary; treat thea2atool accordingly. - Remote answers are untrusted text. Results and questions arrive as prompts in the owner's chat; the model sees
them labelled
[A2A result from "<agent>" ...], but a remote agent can still try to talk it into things. - Special files aren't detected: attaching a FIFO blocks the tool until the run is aborted.
- Every single remote call is bounded by 60 seconds (see The a2a tool).
Volcanic to Volcanic
Two assistants, A (the remote) and B (the caller). On A, the server is on and A's workspace has a caller entry:
# A: the sample host over one folder (or a Telegram bot with A2A__Enabled=true)
mkdir -p /tmp/a-ws/.volcanic/a2a
echo '[ { "name": "assistant-b", "key": "k_b_0123456789abcdef" } ]' > /tmp/a-ws/.volcanic/a2a/callers.json
ASPNETCORE_URLS=http://localhost:5099 dotnet run --project samples/Volcanic.AgenticCore.Sample.A2A -- --faux --workspace /tmp/a-ws
On B, the client is on and the chat's workspace lists A under the same key:
mkdir -p ~/.volcanic/telegram-workspaces/111111111/.volcanic/a2a
echo '[ { "name": "assistant-a", "url": "http://localhost:5099/", "key": "k_b_0123456789abcdef" } ]' \
> ~/.volcanic/telegram-workspaces/111111111/.volcanic/a2a/agents.json
A2AClient__Enabled=true TELEGRAM_BOT_TOKEN=... TELEGRAM_ALLOWED_USER_IDS=111111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram
Ask B "ask assistant-a what it can do": B's model calls a2a with action=agents, then send. On A the message
runs in an isolated session for caller assistant-b; its answer comes back as B's tool result, or later as a
follow-up if it takes longer than WaitSeconds. /a2a on B lists assistant-a and any running watches; on A (in
Telegram) it lists caller assistant-b and its tasks. With both bots public, give B's server a PublicUrl too, and
B's watches get pushes from A instead of polling.