* feat(a2a): add A2A v1.0 agent-interop channel plugin Adds `a2a` as a bundled, default-off channel plugin so A2A-compliant agents (Hermes Agent, LangChain, CrewAI, Google ADK) can discover an OpenClaw gateway and send it tasks, and so OpenClaw can address configured peers. Inbound HTTP (`/.well-known/agent-card.json`, `/a2a/v1`) runs through the normal channel ingress path, so peer messages inherit allowlist admission, provenance framing, and session routing. The agent reply returns through the channel delivery callback and resolves the A2A task with its artifact, correlated per (peer, contextId) FIFO so concurrent sends cannot cross-talk. Wire format follows the canonical v1.0.0 spec sources (a2aproject/A2A specification/a2a.proto): PascalCase SendMessage/GetTask/CancelTask, supportedInterfaces[] rather than the 0.3-era top-level url/protocolVersion, member-presence Part oneof, TASK_STATE_*/ROLE_* enums, no Task.kind. The 0.3 dotted method names are accepted as explicit compat aliases because shipped Hermes-generation peers still send them. Security: per-peer bearer tokens compared in constant time, per-peer sliding window rate limit, 1 MiB body cap, 64 KiB inbound text cap, tasks scoped to their owning peer, outbound redirect: "error", and no inbound-controlled target URLs. With no peers configured the channel stays unconfigured and registers no routes. Live-verified against a real gateway on an isolated state dir with a mock provider: 31/31 proofs, including the agent reply round-tripping into the task artifact. That run caught two defects fixed here: - The agent card read `cfg.agents.list` directly, so gateways configured with the canonical `agents.entries` roster advertised zero skills. Now uses listAgentIds/resolveAgentConfig, which read either roster shape. - `returnImmediately` dispatches inherited the HTTP request's released work- admission root and every async task failed as GatewayDrainingError. Now reserves an independent root via runDetachedWebhookWork, matching sms/line/zalo. * fix(a2a): route outbound sends through the SSRF guard and land setup metadata CI on the first push surfaced four real defects that local `check:changed` did not cover (it runs no oxlint or typecheck lane): - Outbound peer sends used raw `fetch()`, tripping the channel/plugin no-raw-fetch boundary. They now go through `fetchWithSsrFGuard` with `maxRedirects: 0`, so A2A egress carries the same SSRF policy as every other plugin call and a redirect cannot re-target a delivered task. - The channel declared an empty setup contract while its package manifest declared none, so the bundled-channel shape contract failed. The wizard now collects `advertisedUrl` plus a first peer name/token pair, which is the minimum that leaves A2A actually configured (it stays unconfigured until one peer credential exists), and the package manifest mirrors that metadata. - Six lint errors in the test files: base-to-string on `BodyInit` bodies, an unused mock, and a shadowed `options` parameter. Request bodies now go through a typed reader that asserts the serialized-string contract before parsing. - A card assertion referenced `capabilities` without declaring it on the local response type, failing test typecheck. Outbound redirect handling moves from `redirect: "error"` to the guard's `manual` inspection; the adapter tests assert the new shape. * fix(a2a): drop route replaceExisting, accept configWrites, register runtime api CI after the interop run surfaced four more gates: - `registerPluginHttpRoute` used `replaceExisting: true`, tripping the GHSA-RQP8-Q22P-5J9Q rule: a duplicate path can silently replace another account's handler. A2A owns fixed global paths on a single account, so a duplicate registration means a stale or conflicting owner. It now fails loudly through `throwOnFailure` instead. - The channel schema rejected `channels.a2a.configWrites`, which every bundled channel must accept; added to the zod schema, the manifest schema, and the config type, then regenerated the channel metadata. - The new `input` assertion in the setup adapter needed a `// SAFETY:` line for the assertion ratchet. - `extensions/a2a/runtime-api.ts` needed registering in the runtime-api classification list, like every other bundled channel barrel. Also repairs a pre-existing incomplete `vi.mock` factory for `../infra/fs-safe.js` in the agent-delete suites. Both pass in isolation, but the shared-worker lane lets a sibling importer reach `readLocalFileSafely`, which the partial factory never exported; adding this channel changed shard composition and surfaced it. The factory now spreads the real module and overrides only `movePathToTrash`. * chore(a2a): refresh config baseline for the configWrites surface * chore(tests): adopt main's fs-safe mock repair for the agent-delete suites Main landed the same importOriginal spread for the shared-worker mock-defeat while this branch carried an equivalent fix; take main's version verbatim so the two do not conflict. * fix(a2a): isolate peer sessions and stop faking task cancellation Addresses both ClawSweeper P1 findings. Peer sessions: inbound routing never passed `dmScope`, so it fell back to `session.dmScope ?? "main"` and every authenticated peer converged on `agent:main:main` under default config. Untrusted remote content was joining the operator's own session, and peers shared conversation history. A2A now pins `per-account-channel-peer`; the peer id already embeds the A2A contextId, so each peer+context pair gets its own session regardless of global session config. Cancellation: `CancelTask` marked the task `TASK_STATE_CANCELED` and discarded the late reply, but the dispatched agent run kept going and could still use tools. The peer was told the work stopped when it had not. There is no plugin-facing seam to abort a live run, so cancellation is refused with -32004 instead of acknowledged, and the store's cancel path plus its canceled-delivery tombstone are deleted rather than left as a lying terminal state. Documented as an explicit limitation. Live-verified on a real gateway: 34/34 proofs, including three distinct `agent:main:a2a:default:direct:<peer>:<context>` sessions with zero traffic in `agent:main:main`, and both cancel spellings refused while the task stays WORKING. Re-ran the official a2a-sdk 1.1.2 Docker interop afterwards: 9/9.
8.5 KiB
summary, read_when, title
| summary | read_when | title | ||
|---|---|---|---|---|
| Connect external agents to OpenClaw through the A2A 1.0 JSON-RPC protocol |
|
A2A |
The A2A channel plugin connects OpenClaw to other agents through the Linux Foundation Agent2Agent protocol. External agents discover the gateway through a public Agent Card and submit authenticated text tasks using the A2A 1.0 JSON-RPC binding. OpenClaw can also send messages to configured peer agents.
Quick setup
Add the bundled plugin to your OpenClaw configuration and define a separate bearer token for each trusted peer:
{
channels: {
a2a: {
enabled: true,
advertisedUrl: "https://openclaw.example.com",
peers: {
hermes: {
token: "${A2A_HERMES_TOKEN}",
},
},
},
},
}
Set A2A_HERMES_TOKEN to a strong, unique secret in the gateway environment, then restart the gateway. Use your externally reachable HTTPS origin as advertisedUrl when the gateway runs behind a reverse proxy. If omitted, the plugin derives the advertised origin from the incoming discovery request.
Discover the Agent Card
Fetch the public A2A Agent Card without authentication:
curl http://127.0.0.1:18789/.well-known/agent-card.json
The card advertises the gateway JSON-RPC endpoint, supported text input and output, and one skill for each exposed OpenClaw agent. Set channels.a2a.exposeAgents to an array of agent IDs to limit which agents appear. If unset or empty, all configured agents are advertised.
/.well-known/agent.json returns the same card for older A2A clients.
Send a task
Send an authenticated SendMessage JSON-RPC request to /a2a/v1:
curl http://127.0.0.1:18789/a2a/v1 \
-H "Authorization: Bearer $A2A_HERMES_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "request-1",
"method": "SendMessage",
"params": {
"message": {
"messageId": "message-1",
"role": "ROLE_USER",
"parts": [{ "text": "Summarize my latest project updates." }]
}
}
}'
By default, the request waits for the agent response. A completed response contains a task with the reply in its artifact:
{
"jsonrpc": "2.0",
"id": "request-1",
"result": {
"task": {
"id": "<task-id>",
"contextId": "<context-id>",
"status": {
"state": "TASK_STATE_COMPLETED",
"timestamp": "2026-01-01T12:00:00.000Z"
},
"artifacts": [
{
"artifactId": "<artifact-id>",
"parts": [{ "text": "Here are your latest project updates..." }]
}
],
"history": []
}
}
}
Include message.contextId on subsequent requests to continue the same conversation. Context IDs can contain letters, numbers, periods, underscores, colons, and hyphens, and must not exceed 128 characters.
To return immediately while the agent continues working, add "configuration": { "returnImmediately": true } alongside "message" in params. The task initially reports TASK_STATE_WORKING. Requests that exceed replyTimeoutMs also return the current working task instead of canceling it.
Older clients can use message/send as an alias for SendMessage.
Poll a task
Poll a task by sending its ID to GetTask:
curl http://127.0.0.1:18789/a2a/v1 \
-H "Authorization: Bearer $A2A_HERMES_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "poll-1",
"method": "GetTask",
"params": { "id": "<task-id>" }
}'
The task transitions from TASK_STATE_WORKING to TASK_STATE_COMPLETED, TASK_STATE_FAILED, or TASK_STATE_REJECTED. Older clients can use tasks/get as a compatibility alias.
CancelTask is refused with JSON-RPC error -32004 rather than acknowledged. A dispatched agent run has no plugin-facing abort seam, so reporting TASK_STATE_CANCELED would tell the peer the work stopped while the run kept using tools. Refusing keeps the reported state honest.
Configure outbound peers
Add a peer URL when OpenClaw should send messages to another A2A agent. Set outboundToken when the remote agent requires its own bearer token:
{
channels: {
a2a: {
enabled: true,
peers: {
hermes: {
token: "${A2A_HERMES_TOKEN}",
url: "https://hermes.example.com/a2a/v1",
outboundToken: "${A2A_HERMES_OUTBOUND_TOKEN}",
},
},
},
},
}
Address outbound messages to a2a:hermes. The plugin sends SendMessage directly to the configured URL without performing Agent Card discovery. Outbound messages reuse a stable conversation context per peer. A peer without a configured url cannot receive outbound messages.
Configuration reference
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | - | Enables or disables the A2A channel. |
advertisedUrl |
string | request | Public gateway origin used in the Agent Card. |
replyTimeoutMs |
number | 120000 |
Maximum blocking reply wait; allowed range is 5000 to 600000 milliseconds. |
rateLimitPerMinute |
number | 30 |
Sliding-window request limit per peer; 0 disables the limit. |
exposeAgents |
string[] | all | Agent IDs advertised as Agent Card skills. |
peers |
object | {} |
Trusted peers keyed by lowercase names up to 64 characters. |
peers.<name>.token |
string | required | Bearer token required when this peer sends requests to OpenClaw. |
peers.<name>.url |
string | - | Peer JSON-RPC endpoint for outbound messages. |
peers.<name>.outboundToken |
string | - | Bearer token OpenClaw sends to the configured peer URL. |
Peer names must begin with a lowercase letter or number and can also contain periods, underscores, and hyphens.
Session isolation
Each authenticated peer and A2A contextId pair gets its own agent session. A2A pins the most
isolated direct-message scope rather than inheriting session.dmScope, so remote peer content never
joins the operator's main session and one peer cannot read another peer's conversation history.
Security
Agent Card discovery is intentionally public: anyone who can reach the gateway can read the instance description and exposed agent IDs. Use exposeAgents to limit disclosure, and expose the gateway through HTTPS when it is reachable over an untrusted network.
Every JSON-RPC request requires a configured peer bearer token; there is no unauthenticated mode. Each authenticated peer is also the sender identity used for normal OpenClaw channel ingress policy. Use different high-entropy tokens for each peer, keep tokens out of source control, and rotate tokens by updating the gateway environment and restarting.
Requests are limited to 1 MiB. Extracted message text is capped at 64 KiB and includes an explicit truncation marker when shortened. The default sliding-window limit is 30 requests per minute for each peer; set rateLimitPerMinute to 0 only on a separately protected network. Rate-limited requests return a JSON-RPC error while keeping HTTP status 200.
Outbound destinations come only from operator-configured peer URLs. Inbound callers cannot supply a proxy target or redirect OpenClaw to another destination.
A2A 1.0 limitations
The current plugin supports text messages and structured JSON data parts, which are appended as compact JSON text. File URL and raw binary parts are ignored. Streaming, server-sent events, push notifications, task cancellation, task listing, extended Agent Cards, and multi-tenant routing are not supported.
Tasks remain in memory only. Completed and other terminal tasks are retained for up to 24 hours, with a maximum of 500 retained entries; restarting the gateway discards all tasks and task history.