# 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](#scenarios) to [curl examples](#curl-examples) describe it. - **Client** (`extensions/Volcanic.AgenticCore.Extensions.A2A`, `A2AClient__Enabled`): the assistant calls other agents through its `a2a` tool. See [Client](#client). ## Server `src/Volcanic.AgenticCore.A2A.Server` lets other agents talk to a Volcanic assistant over [A2A](https://a2a-protocol.org/latest/specification) (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](#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 `/.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): ```json [ { "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: ```json { "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](#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](#agentsjson)) and `pending.json` (its watches of remote tasks; see [Watches](#watches)). `tasks/.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: ` or `Authorization: Bearer `. 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: `-a2a-_` (the Telegram host's `ScopePrefix` is `telegram-`); 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](#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): ```bash 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: ```bash # 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":""}}' ``` ## 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](#server) in particular ([example](#volcanic-to-volcanic)). ### 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](#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](#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 `). ### agents.json `/.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. ```json [ { "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 `/.well-known/agent-card.json`. A URL ending in `.json` is taken as the card URL itself. - `key`: optional. Sent as `Authorization: Bearer ` 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 ()` | | `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](#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 (context ) 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=`; the model asks the user first when it doesn't know the answer. - **Still running**: a [watch](#watches) 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 ):` 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 `/inbox/a2a//` (for a plain message reply, ``), 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: ... ()`, `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 `/a2a/push/` 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... 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 `/.volcanic/a2a/pending.json`: ```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](#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 "" ...]`, 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](#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: ```bash # 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: ```bash 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.