Files
Playa/docs/a2a.md
EugeneTes cb877f697a fix(a2a-client): poll watches crashed on null delays; lost tasks and crashed watches end with a notice
- A2AWatchRegistry.Shared was initialised before DefaultPollDelays (static fields run in source order), so the bot's
  registry had null poll delays and every poll watch crashed on its first wait, then sat in the registry doing
  nothing until a restart. The delays now come first; a test reads them through Shared.
- TaskNotFound from the remote ends the watch at once with a "(lost)" notice instead of retrying for a day.
- A crashed watch loop restarts by polling after a pause; the third crash in a row ends it with a "(stopped)" notice.
2026-10-16 14:46:59 +00:00

31 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 its a2a tool. 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 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.

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_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;
    • with the client on, forging .volcanic/a2a/pending.json: a record with an undelivered text runs as a prompt in the owner's chat on the next restart or session start;
    • editing .volcanic/a2a/agents.json to point an agent name somewhere else, so the owner's next send to 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.

  • 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>"}}'

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 as agent.
  • url: absolute http(s) base URL; the card is fetched from <url>/.well-known/agent-card.json. A URL ending in .json is taken as the card URL itself.
  • key: optional. Sent as Authorization: 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-Key when 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; a settled task's watch ends (its outcome is in the conversation now, so it isn't delivered again)
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 is Agent "travel-bot": task <taskId> (context <contextId>) is completed., then the answer, then an optional Files: list (see "Files out" below). The answer is the response artifact, else every text artifact, else the status message. For input-required/auth-required the question (status message) comes first, followed by the hint to reply with action=send, the agent and taskId=<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 while send waits: 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: every file part of the answer is saved to <workspace>/inbox/a2a/<taskId>/ (for a plain message reply, <messageId>), whether it sits in an artifact, in the status message or in an agent message after our last one in the task history. Inline bytes are written; URL parts are downloaded, streamed, with the agent's key only when the URL is on the agent's own origin (its agents.json URL or a card interface), and otherwise only over https and without the key. Each file may be at most MaxReceivedFileBytes (200 MB); a failed or partial download leaves nothing behind. Names come from the part (else the URL's last segment, else the artifact), sanitised, with -2, -3, ... when two parts of one answer share a name; the same part found twice (say, artifact and status message) is saved once. The result lists each file with its size and media type, or why it was not saved:

Files:
- inbox/a2a/eff0e973.../SKILL.md (5222 bytes, text/markdown)
- crm.tar.gz (320000000 bytes, application/gzip): not saved, over 209715200 bytes

The SDK speaks protocol 1.0 and would silently drop file parts in other shapes, so every A2A response (JSON and SSE) first passes A2AWireCompat: the 0.3 shape {"kind":"file","file":{"bytes"|"uri","name","mimeType"}} (or those fields directly on the part) becomes {"raw"|"url","filename","mediaType"}, and a part it still can't read is listed as - part: not saved, not understood (fields: ...) and logged with its field names (never values).

Saving a task again overwrites its folder's files, so action=status with the taskId of a finished task fetches its files again (as long as the remote agent still returns them). Watch deliveries ([A2A result from …]) carry the same list.

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. A send with a contextId that the remote refuses as busy (UnsupportedOperation mentioning "busy", as ours and VolcanicAgents do) says that nothing was sent, names the task this chat watches in that context (its result will arrive here by itself) and suggests sending again after it or without contextId. A refusal never touches a watch. 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:

  1. push: only when the server is on with A2A__PublicUrl set (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 with GetTask; the body is never trusted. A safety GetTask runs every 10 minutes. If the remote refuses the push config, the watch subscribes or polls instead.
  2. 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. No stream is held for more than 5 minutes: the watch then reads the task with GetTask and subscribes again (a pause, not a failure). Remotes send nothing while their agent works without writing text, and such a quiet stream can die on the way (a proxy or NAT dropping it) without either side noticing; before this slice a watch could wait on a dead stream until WatchTimeout.
  3. poll: GetTask after 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. A remote that answers TaskNotFound has forgotten the task (typically it restarted): the watch ends at once with a (lost) notice suggesting to send the request again. A crash of a watch's loop (a bug, not a remote failure) restarts it by polling after 1 s, 5 s, 15 s; the third crash in a row ends it with a (stopped) notice suggesting action=status, so no watch stays listed while doing nothing. The shared handler turns on TCP keepalive (probes after 60 s of silence, every 15 s, 4 tries) and HTTP/2 pings, so a dead connection fails instead of hanging. The log has one line when a watch starts or is restored (task, agent, chat, source, watch id), when a stream slice ends on a running task (subscribing again), when a result is delivered or kept, and for every failed check. 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/5c1e.../itinerary.pdf (48213 bytes, application/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
MaxReceivedFileBytes 209715200 (200 MB) Cap per file the remote agent sends back (inline or downloaded)

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.json keys are plaintext in the workspace, like callers.json: anything that reads the workspace (the agent's bash, 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 bash in the shared workspace: forge pending.json (an undelivered text runs as a prompt in the owner's chat on restart or session start) or redirect an agent name in agents.json (the owner's next send, its attachments and the key go to the new URL). Accepted v1 exposures, like jobs.json (see Security).
  • The agent chooses where data goes. It can edit agents.json like 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 through bash (curl), not a new boundary; treat the a2a tool 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.