@bacnh85/pi-a2a

A2A Protocol v1.0 bidirectional extension for the Pi coding agent — distribute tasks to remote agents, be called by them, and self-declare for local session discovery.

Packages

Package details

extensionskill

Install @bacnh85/pi-a2a from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@bacnh85/pi-a2a
Package
@bacnh85/pi-a2a
Version
0.7.9
Published
Sep 19, 2026
Downloads
1,567/mo · 446/wk
Author
bacnh85
License
MIT
Types
extension, skill
Size
359.4 KB
Dependencies
1 dependency · 3 peers
Pi manifest JSON
{
  "skills": [
    "./skills"
  ],
  "extensions": [
    "./extensions/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@bacnh85/pi-a2a

A2A Protocol v1.0 bidirectional extension for the Pi coding agent.

Pi becomes a first-class Agent2Agent peer: it can distribute tasks to remote agents (Hermes, Google ADK, LangChain, CrewAI, anything A2A-compliant) and be called by them. Follows the upstream A2A Protocol v1.0 — JSON-RPC 2.0 over HTTP, Agent Card discovery, task lifecycle, streaming.

  • Zero runtime dependencies — pure Node.js stdlib + global fetch
  • Outbound always available; inbound opt-in (default off)
  • Security model ported from Hermes: localhost-default bind, token-gated remote, outbound redaction, inbound injection filtering, audit log, anti-loop

Install

pi install npm:@bacnh85/pi-a2a
# or from the monorepo
pi install ./pi-a2a

Then /reload in Pi.

Quick start

Call a remote agent

# Discover what an agent can do
/a2a-discover http://localhost:9900

# Send it a task
/a2a-send hermes_desktop "Summarize today's arXiv postings on retrieval-augmented generation"

Or let the model delegate via the a2a_call tool:

"Ask the researcher agent to summarize today's arXiv postings."

Be callable by other agents

/a2a-server start
# → A2A server listening on 127.0.0.1:9910.
#   Agent Card: http://127.0.0.1:9910/.well-known/agent-card.json

Other agents can now discover and call Pi:

curl http://127.0.0.1:9910/.well-known/agent-card.json

curl -X POST http://127.0.0.1:9910/ \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage",
       "params":{"message":{"messageId":"m1","role":"ROLE_USER",
                 "parts":[{"text":"Find all TODO comments in src/"}]}}}'

Configuration

Edit ~/.pi/agent/settings.json under the a2a key. Key reference:

Key Default Meaning
peers {} Static peer directory: name → { url, auth, timeout, capabilities }
selfIdentity "" Outbound caller identity — a key in server.peerTokens (unique audit attribution)
server.enabled false Auto-start the inbound server on session start
server.port 9910 Inbound port
server.portFallback 10 Consecutive ports to try if busy (then OS-assigned)
server.host 127.0.0.1 Bind host (widen to 0.0.0.0 ONLY with a token set)
server.workspace cwd Workspace for inbound isolated sessions
server.agentName hostname Name on the Agent Card
server.publicUrl "" Externally-routable URL for the Agent Card (reverse proxy / k8s)
server.sharedToken "" Shared bearer token for the inbound server
server.peerTokens {} Per-peer token directory name → token (unique identities)
server.trustedPeers [] Allow-list of authenticated identities
server.allowAllUsers false Allow any authenticated peer (dev only)
server.childTranscripts true Persist each dispatched child session's transcript to <agentDir>/a2a_sessions/<timestamp>_<taskId>.jsonl (a real pi session file, openable with pi's session tooling) so stalled/killed workers leave a forensic step history. Off = stock in-memory children
server.childTranscriptRetentionDays 30 Delete child transcripts older than N days on server start. 0 = keep forever. Transcripts carry everything the worker read — keep the window bounded
server.maxConcurrent 3 Max concurrent inbound tasks (blocking and detached — see the operator note under Non-blocking dispatch)
server.replyTimeoutSec 300 Seconds to wait for the agent's reply on a blocking send. 0 = no reply-window timer (unbounded — the request stays open until the run settles; deliberate, rare)
server.asyncTimeoutSec 86400 Supervision window (seconds) for detached tasks sent with returnImmediately — the caller's request already returned an ack, so this bound replaces the reply window. 0 = unbounded (caller-supervised via tasks/get / tasks/cancel)
server.maxPingpongTurns 5 Anti-loop turn cap per context (max 20)
server.rateLimitPerMin 60 Requests/minute per identity
server.skills [] Skills advertised on the Agent Card. When empty (default), skills are self-discovered from the live session — no config needed
timeouts.send 360000 Outbound send timeout (ms); kept above the default 300s server reply deadline
timeouts.async 30000 Async task poll interval (ms)
timeouts.stream 120000 Streaming timeout (ms)
retryAttempts 2 Outbound retry count
verifySsl true Verify TLS on outbound calls
discovery.local {enabled:true, heartbeatSec:15, ttlSec:60} Local file registry
discovery.mdns {enabled:false, serviceType:"a2a"} mDNS broadcast + discovery
discovery.gateway (unset) Legacy single upstream a2a-switchboard registration (enabled, url, token, name, upstreamToken, heartbeatSec, channel) — see a2a-switchboard gateway
discovery.gateways (unset) Multiple upstream a2a-switchboard gateways (0.6.0): a keyed map { <key>: {enabled, url, token, ...} } — each gateway registers + heartbeats independently; peers appear as gw/<key>/<name>
discovery.enrichCard true Publish session metadata into the Agent Card
ui.transcript true Show inbound activity as transcript messages

Example:

{
  "a2a": {
    "peers": {
      "hermes_desktop": {
        "url": "http://172.30.55.31:9900",
        "auth": { "type": "bearer", "token": "..." },
        "timeout": 120,
        "capabilities": ["web_search", "research"]
      },
      "researcher": {
        "url": "http://localhost:9999",
        "auth": { "type": "bearer", "token": "..." },
        "capabilities": ["research"]
      }
    },
    "server": {
      "enabled": false,       // set true to auto-start the inbound server
      "port": 9910,
      "portFallback": 10,     // if port is busy (another Pi session), climb to port+1 … +10, then OS-assigned
      "host": "127.0.0.1",    // widen to 0.0.0.0 ONLY with a token set
      "workspace": "",        // defaults to the host session's cwd
      "agentName": "pi"
      // "skills": [...] — optional override; when omitted the card
      // self-discovers the session's loaded skills
    },
    "timeouts": { "send": 360000, "async": 30000, "stream": 120000 }
  }
}

Peers can also be addressed by direct URL (no config needed): a2a_call with agent: "http://host:port" works. For loopback URLs that match a known peer (configured a2a.peers or a live local-registry entry), the session's shared token is auto-attached so discovered local Pi sessions are callable without manual per-peer config. Arbitrary loopback URLs never receive the token (prompt-injection guard).

Multiple Pi sessions (port fallback)

a2a.server.enabled: true is per-session — every Pi session with it enabled starts its own inbound server. With a global setting, the first session binds the configured port; subsequent sessions hit EADDRINUSE and automatically climb to the next free port (port+1port+10, then an OS-assigned port), so they start cleanly instead of failing. Each session's Agent Card advertises the port it actually bound, so peers can always call it back.

To avoid surprise ports, prefer enabling the server per project — via env (A2A_SERVER_ENABLED=true from your shell or the global ~/.pi/agent/.env.local) or the global ~/.pi/agent/settings.json keyed per project. Note: a project-local .pi/settings.json can no longer enable the server — security-relevant keys (server.enabled, tokens, host, gateway, …) are ignored from repo-controlled files, because a checked-out repo must not be able to turn on an inbound agent server on whoever opens it. (portFallback: 0 means "configured port only, straight to OS-assigned on conflict".)

Environment variables

Env Default Meaning
A2A_BEARER_TOKEN (unset) Shared bearer token for the inbound server
A2A_PEER_TOKENS (unset) Per-peer tokens name:token,...
A2A_HOST 127.0.0.1 Inbound bind host (only widens with a token set)
A2A_PORT 9910 Inbound port
A2A_PORT_FALLBACK 10 Consecutive ports to try if A2A_PORT is busy before OS-assigned
A2A_AGENT_NAME hostname Name on the Agent Card
A2A_PUBLIC_URL (unset) Externally-routable URL for the Agent Card (reverse proxy / k8s)
A2A_TRUSTED_PEERS (unset) Allow-list of authenticated identities
A2A_ALLOW_ALL_USERS false Allow any authenticated peer (dev only)
A2A_RATE_LIMIT 60 Requests/minute per identity
A2A_MAX_PINGPONG_TURNS 5 Anti-loop turn cap per context (max 20)
A2A_REPLY_TIMEOUT 300 Seconds to wait for the agent's reply (0 = unbounded: no reply-window timer)
A2A_ASYNC_TIMEOUT 86400 Detached-task supervision window in seconds (0 = unbounded) — see Non-blocking dispatch
A2A_CHILD_TRANSCRIPTS true Persist dispatched child-session transcripts to disk (false = in-memory only)
A2A_CHILD_TRANSCRIPT_RETENTION_DAYS 30 Days to keep child-session transcripts before cleanup
A2A_SERVER_ENABLED false Auto-start the inbound server on session start
A2A_SELF_IDENTITY (unset) Outbound caller identity: a key in server.peerTokens. When set, this session presents its OWN per-peer token (not the shared token) so receivers attribute calls to it uniquely. Empty = use the shared token (anonymous caller).
A2A_DISCOVERY_LOCAL true Enable the local file registry
A2A_DISCOVERY_MDNS false Enable mDNS broadcast + discovery
A2A_MDNS_TYPE a2a mDNS service type (advertised as _<type>._tcp)
A2A_GATEWAY_URL (unset) Agent-gateway base URL (with A2A_GATEWAY_TOKEN)
A2A_GATEWAY_TOKEN (unset) Agent-gateway API/bearer token
A2A_GATEWAY_ENABLED (see gateway) Explicit on/off for gateway registration; defaults to true when url+token are set
A2A_HEARTBEAT_SEC 15 Registry heartbeat interval (seconds)
A2A_TTL_SEC 60 Registry entry TTL before stale-sweep (seconds)
A2A_ENRICH_CARD true Publish session metadata into the Agent Card
A2A_UI_TRANSCRIPT true Show inbound task activity as transcript messages (false = toasts + footer only)

Session discovery (0.2.0)

Each inbound session self-declares its identity so peers can find it and judge whether it's the right delegate (working folder, model, abilities) — no port-scanning, no manual config. Three layers, all on by default (except mDNS):

  1. Local file registry (zero-dep) — <piDir>/a2a_registry/<pid>.json. Instant same-machine discovery with dual stale-GC (mtime TTL + pid liveness probe). The proven pattern (VS Code server, Emacs server, tmux).
  2. Enriched Agent Card — the card advertises an A2A v1.0 Extension + a metadata map with pid/cwd/model/tools. Any A2A peer reads it.
  3. mDNS (_a2a._tcp) — cross-machine LAN discovery. Optional: install bonjour-service to enable (npm i bonjour-service); without it the extension degrades to the file registry + card. Each session advertises a unique, pid-suffixed name (<hostname>-a2a-<pid>.local), never the OS local hostname — so enabling mDNS cannot make macOS rename the machine ("local hostname already in use"). If an earlier 0.3.0 session already triggered a rename, restore it: System Settings → General → Sharing, or sudo scutil --set LocalHostName <name>.

Configure under a2a.discovery in settings.json:

"a2a": {
  "discovery": {
    "local": { "enabled": true, "heartbeatSec": 15, "ttlSec": 60 },
    "mdns":  { "enabled": false, "serviceType": "a2a" },
    "enrichCard": true
  }
}

List discovered peers with the a2a_peers tool or /a2a-peers command — they show each peer's name, url, source (local/mdns/config), cwd, model, and tools so you (or the model) pick the right one before a2a_call.

a2a-switchboard gateway

Register this session with a self-hosted a2a-switchboard so other accepted peers discover and call it through the gateway's proxy (gateway peers appear as gw/<key>/<name> and carry the gateway bearer token). Configure under a2a.discovery.gateway (single) or a2a.discovery.gateways (multiple) in settings.json (or the /a2a-config panel → Gateway (discovery.gateway) / Gateways (discovery.gateways) groups; the legacy single- gateway group is hidden when its block is inert — all-empty + disabled):

"a2a": {
  "discovery": {
    "gateway": {
      "enabled": true,              // explicit on/off; defaults true when url+token set
      "url": "http://127.0.0.1:9920",
      "token": "<gateway-api-token>",
      "name": "pi-s2-9913",          // optional; default <agentName>-<port>
      "upstreamToken": "...",         // optional: token the gateway presents when proxying TO us
      "heartbeatSec": 60,             // re-register interval
      "channel": true                 // reverse channel so firewalled peers can call us
    },
    "gateways": {                     // 0.6.0: multiple gateways
      "work": { "enabled": true, "url": "http://10.0.0.5:9920", "token": "..." },
      "lab":  { "enabled": true, "url": "http://127.0.0.1:9921", "token": "...", "channel": false }
    }
  }
}
  • Registration is a per-session upstream per gateway: each configured gateway registers itself, re-registers on heartbeat, and deregisters on graceful stop — independently of the others.

  • enabled: false (or A2A_GATEWAY_ENABLED=false) disables registration entirely while keeping the url/token for later.

  • Peer naming (0.6.0, breaking): gateway-proxy peers are ALWAYS prefixed with the gateway key — gw/<key>/<name>. The single legacy gateway block uses a key derived from its URL host-port (e.g. gw/127.0.0.1-9920/<name>), so the old gw/<name> names no longer resolve. Use the gateways map with a stable key if you want predictable names. Add/remove gateways from the /a2a-config panel (Gateways group).

  • The panel renders tokens masked (••••); the token is stored in settings.json in plaintext (same as server.sharedToken) — use env A2A_GATEWAY_TOKEN if you'd rather keep secrets out of files (env feeds the single gateway block).

  • Heartbeat method (PATCH /register): the first registration with no known caller_token (first ever, or the state file was lost) is a POST /register with the shared token — the only path that mints the per-peer caller_token. Once known, every heartbeat switches to PATCH /register authenticated as that caller_token (full card refresh; re-sending the URL covers IP changes). On 405 (old switchboard) or 401 (stale token) the heartbeat falls back to POST and the fallback re-mints when the response carries a new caller_token.

    Situation Method Auth
    First register / unknown peer POST /register shared token
    Steady-state heartbeat (caller_token known) PATCH /register caller_token
    PATCH unsupported (405) or rejected (401) POST /register shared token

    The caller_token is disclosed by the gateway only at mint, so it is persisted per gateway key and peer name at ~/.pi/agent/a2a_gateways/<key>/<name>.json ({name, callerToken}) — after a restart, heartbeats PATCH immediately instead of re-minting. If both the file and caller token are lost while the old peer still exists, delete that peer in the switchboard admin UI once, then restart Pi; the next POST mints a replacement token.

  • The directory fetch (GET /.well-known/agent.json) always uses the shared token — unchanged by PATCH heartbeats.

  • Failed registers back off exponentially (capped at 5 min). A numeric Retry-After header is honored when larger, the heartbeat pauses while backed off, and success resets the backoff — no user action needed.

Per-session identity (unique caller attribution)

When all sessions share one sharedToken, every caller authenticates as the SAME anonymous identity (ip:127.0.0.1) — the audit log can't tell callers apart, and the rate-limit bucket is pooled across all local sessions. To give each session a unique identity, add a global peerTokens directory and set this session's self-identity:

"a2a": {
  "selfIdentity": "session-a",          // ← this session presents session-a's token
  "server": {
    "sharedToken": "...fallback...",
    "peerTokens": {                        // ← global directory every session reads
      "session-a": "<tokenA>",
      "session-b": "<tokenB>"
    }
  }
}

Or per-terminal via env: A2A_SELF_IDENTITY=session-a.

  • The token a session presents outbound is peerTokens[selfIdentity] (falls back to sharedToken if unset/absent).
  • The receiver matches the presented token against peerTokens FIRST, so the call is attributed to session-a (unique audit entry + separate rate-limit bucket). Only tokens NOT in peerTokens fall through to the shared identity.
  • Sessions still reach each other: A presents tokenA, B's server looks it up → identity session-a; no caller needs anyone else's token.

Outbound asserted identity (X-A2A-Identity)

Every outbound peer request carries the caller's configured identity (selfIdentity, falling back to server.agentName) as an X-A2A-Identity header, in addition to the pi/self message metadata. The use case: when peers live behind a reverse proxy (or on other machines reached through one), every caller arrives from the proxy's address — ip:127.0.0.1 tells the receiver nothing about WHICH agent dispatched. The header lets a receiving peer's audit log attribute the call to a name (pi-kimchi, librarian-bingsu, …) instead of the address.

  • Asserted display provenance, never authentication. Any client can send any name; a receiving server must only use it to refine the display name of a caller it has ALREADY admitted (e.g. an authenticated shared-token caller), never to grant admission or override a token-authenticated identity. Receivers that don't know the header simply ignore it.
  • The header is omitted when no identity is configured (both empty).

Inbound activity in the host TUI (0.3.0)

When a remote peer sends Pi an A2A task, the host session now shows what's happening — you no longer wonder whether Pi is silently doing work:

  • Arrival[A2A inbound] dispatch from <peer>: <preview> transcript message + a toast.
  • Progress — the isolated session's tool calls and assistant text deltas appear as compact one-line transcript messages (⚙ bash …, ✎ …).
  • Completion[A2A inbound] A2A dispatch <label> completed (12.3s) — <reply preview> or failed: <error> toast + message. A footer status line shows how many inbound dispatches are in flight and from whom.

Protocol task ids are labeled as dispatches (task-1b4f8d1c30d54819a2a-1b4) so a delegated execution reads as one peer-to-peer dispatch, not a todo-list item; the raw id stays in the audit log. Transcripts written by older versions say task from / task <id> completed — same meaning.

Toggle with a2a.ui.transcript (default true):

"a2a": { "ui": { "transcript": false } }  // toasts + footer only

Note: transcript messages are role: custom and enter the host agent's LLM context as user-role content. They're kept terse and prefixed with [A2A inbound] so the host agent treats them as informational. Set ui.transcript: false if you'd rather keep inbound activity out of context entirely.

Interactive config panel (0.3.0)

Stop hand-editing settings.json — run /a2a-config in TUI mode to open an arrow-key panel covering Server (enabled, port, host, agent name, reply timeout, concurrency, rate limit), Discovery (local registry, mDNS, enrich-card), Identity (selfIdentity), Peers (edit URLs, add/remove), and UI (transcript toggle).

  • ↑/↓ navigate · Enter toggles/edits · Esc to save/close.
  • Saving persists to settings.json (preserving all other keys) and applies live via in-memory overrides — no /reload needed.
  • Changing server/discovery settings while the inbound server is running auto-restarts it.
  • /a2a-config show prints the plain-text summary (also used in non-TUI modes).

Tools

Tool What it does
a2a_call(agent, message, context_id?, async_dispatch?) Send a task to a peer, return its reply; multi-turn via context_id. async_dispatch: true returns an ack with the task id immediately (see Non-blocking dispatch)
a2a_status(agent, task_id, wait_seconds?) Poll a previously dispatched task by id (GetTask); with wait_seconds, poll until terminal or deadline
a2a_discover(url) Fetch and summarize a peer's Agent Card
a2a_list() Configured peers, persisted conversations, metrics
a2a_history(context_id, limit?) Recall a persisted conversation
a2a_orchestrate(capability, message, mode?) Fan-out to all peers advertising a capability (all/first/best)
a2a_peers() List discoverable peers (local registry + mDNS + configured) with cwd/model/tools

Commands

Command Description
/a2a-discover <url> Fetch an agent's Agent Card
/a2a-agents List configured peers
/a2a-send <agent> <msg> Send a task to a peer
/a2a-broadcast <msg> --agents a,b,c Parallel fan-out to listed agents
/a2a-status Metrics + server status
/a2a-config Interactive config panel (TUI); show for summary
/a2a-server start|stop|status Manage the inbound server
/a2a-peers List discoverable peers (local registry + mDNS + configured)
/a2a-help Show help

Safety

  • Secure by default; every widening step is explicit. No token ⇒ the inbound server binds 127.0.0.1; remote exposure requires a bearer token and an explicit a2a.server.host.
  • Per-peer tokens (A2A_PEER_TOKENS="alice:tok1,bob:tok2") give each peer its own credential; the authenticated name drives rate limiting and trust.
  • Token attach is known-peer-only — outbound calls attach the session token ONLY to loopback URLs matching a configured peer or a live local-registry entry. Arbitrary/prompt-injected http://localhost:<port> URLs never receive any credential.
  • Prompt-injection filtering — inbound text is defanged and framed as untrusted peer input. Remote peers cannot invoke operator slash commands.
  • Outbound redaction — credential-shaped strings (API keys, JWTs, tokens, emails) are scrubbed from replies before they leave.
  • Asserted identity is display-only — the X-A2A-Identity header sent outbound names the caller for attribution; it is self-reported, carries no credential, and must never be trusted for admission on the receiving side.
  • Untrusted metadata sanitized — mDNS TXT records and registry files are network/world-readable input; peer names/cwd/model are sanitized (single-line, length-capped) before display.
  • Audit log — every exchange appends to <piDir>/a2a_audit.jsonl.
  • Anti-loop — per-context turn caps stop two agents ping-ponging forever.

Inbound: isolated sessions, not the live TUI

Pi has no platform-adapter API, so an inbound A2A task spawns an isolated Pi agent session (createAgentSession) in the configured workspace, runs it to completion, and returns the reply as a task artifact. The caller gets a reproducible, tool-equipped agent invocation in your repo — not the interactive TUI session. This is the correct boundary for a coding agent (and the same proven path pi-subagent uses).

The isolated session's activity is surfaced to the host TUI as transcript messages + toasts (see "Inbound activity in the host TUI") — you see what the inbound task is doing without it running in your live session.

The isolated session runs the full extension lifecycle: after creation it fires session_start (via bindExtensions, the same thing the SDK's print mode does), so extensions that wire their tools on session start — e.g. pi-mcp-extension's MCP servers — are available to the dispatched agent, and session_shutdown is emitted on completion so extension-started processes are cleaned up. The child never touches the host's own inbound server: server lifecycle handlers are host-session-only.

Long dispatches run with auto-compaction enabled and a keep window scaled to the model's context window, and the runner waits for the session's post-run recovery before reporting completion. Without that, a task that grows near the context window dies misleadingly: pi clamps max_tokens to what remains (down to 1), the model's final turn ends on a length stop with no text, and the task would otherwise complete with the previous turn's stale reply. Such a turn now fails the task (FAILED, status message "no usable reply was produced") instead of masquerading as success.

Non-blocking dispatch (returnImmediately)

A blocking message/send holds the HTTP request open until the agent finishes — so the caller's reply window bounds the worker's runtime: a long job (reindex, multi-file refactor, an ops run) gets killed when the caller stops waiting, even though the caller only wanted to submit it.

A2A v1.0 §3.2.2 SendMessageConfiguration.returnImmediately decouples the two. On the wire it is one extra field:

{
  "jsonrpc": "2.0", "id": 1, "method": "message/send",
  "params": {
    "message": { "role": "ROLE_USER", "parts": [{ "text": "long job" }] },
    "configuration": { "returnImmediately": true }
  }
}

The server acks immediately with the Task in TASK_STATE_WORKING and keeps running it detached from the request. The ack no longer waits for the work; the work no longer dies with the caller's patience. The caller then polls tasks/get (or cancels via tasks/cancel) by task id.

From Pi's tools this is async_dispatch + a2a_status:

a2a_call(agent="worker", message="reindex everything", async_dispatch=true)
# → [A2A → worker · context … · working · detached]
#   Dispatch accepted — task task-1b4f… is running on the peer (non-blocking).
#   Poll the result with a2a_status(agent: "worker", task_id: "task-1b4f…").

a2a_status(agent="worker", task_id="task-1b4f…")          # one fetch
a2a_status(agent="worker", task_id="task-1b4f…", wait_seconds=300)  # poll until terminal
  • A peer that does not understand returnImmediately simply blocks and returns the finished task — a2a_call falls through to the normal reply formatting, so the flag is always safe to send.
  • A fast task may complete before the ack is serialized; the ack then carries the terminal state and artifacts (the client treats it as a normal reply — no polling needed).
  • Detached runs count against server.maxConcurrent exactly like blocking ones, and overflow is supervised by server.asyncTimeoutSec (default 24h, 0 = unbounded/caller-supervised) → TASK_STATE_FAILED with a descriptive status message, mirroring the reply-window semantics.
  • Operator note — a detached run pins a concurrency slot for its whole lifetime. The gate runs before the blocking/detached split, and a detached run holds its maxConcurrent slot from the ack until it reaches a terminal state — up to asyncTimeoutSec (24h by default) each. Three slow or hung detached dispatches at the default maxConcurrent: 3 therefore block all inbound work — blocking message/send included (server busy) — until they drain. Levers: keep asyncTimeoutSec as low as your longest legitimate detached job (it is the kill switch that frees the slot), raise maxConcurrent if you routinely dispatch long jobs, and tasks/cancel a stuck task to free its slot immediately. (A separate maxDetached budget is a plausible follow-up.)
  • returnImmediately has no effect on message/stream (per §3.2.2) — streaming keeps its blocking semantics.
  • The snake_case spelling return_immediately is accepted for early v1.0-draft clients.

For fleet agents: the natural completion signal is still the shared queue (knowfleet) — poll a2a_status only to surface progress or fetch artifacts, not as the source of truth for whether the worker finished its bookkeeping.

Hermes interop

The primary interop target. Hermes (~/.hermes/hermes-agent, A2A platform plugin) implements the same v1.0 wire format bidirectionally, so Pi ↔ Hermes works out of the box:

# Discover the local Hermes
/a2a-discover http://localhost:9900

# Send Pi a task FROM Hermes (once /a2a-server start)
# In Hermes: a2a_call("http://<pi-host>:9910", "Find all TODO comments in src/")

Protocol compliance

Implements the A2A Protocol v1.0 JSON-RPC binding. v1.0 method names (SendMessage, SendStreamingMessage, GetTask, ListTasks, CancelTask, SubscribeToTask) speak the v1.0 wire shapes — SendMessage replies with the oneof {"task": …} / {"message": …} wrapper, streaming emits TaskStatusUpdateEvent / TaskArtifactUpdateEvent frames, and ListTasks returns full Task objects. The pre-1.0 path aliases (message/send, message/stream, tasks/get, …) keep the legacy wire shapes (bare Task results, {id, state} list stubs) so existing peers see no change.

Feature Status
Agent Card (/.well-known/agent-card.json) ✅ + legacy agent.json
message/send (sync)
message/send non-blocking (configuration.returnImmediately) ✅ (see Non-blocking dispatch)
message/stream (SSE)
tasks/get, tasks/list, tasks/cancel, tasks/subscribe
Part types (text, file, data) ✅ (v1.0 + v0.3 tolerant)
Task lifecycle states
Push notifications 🔜 (HMAC signing scaffolded)
gRPC / HTTP-REST bindings Not implemented (JSON-RPC only)

Development

cd pi-a2a
npm install
npm test         # mocha + tsx (198 tests)
npm run typecheck
npm pack --dry-run

Live mDNS tests are opt-in: MDNS_NETWORK_TESTS=1 npm test (they publish real _a2a._tcp records on the local network).

License

MIT