Files
openclaw/docs/plugins/reference/a2a.md
Peter Steinberger ad1e946c7a feat(a2a): add A2A v1.0 agent-interop channel plugin (#130008)
* 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.
2026-08-26 08:04:53 -07:00

380 B

summary, read_when, title
summary read_when title
A2A v1.0 Agent-to-Agent protocol channel plugin.
You are installing, configuring, or auditing the a2a plugin
A2a plugin

A2a plugin

A2A v1.0 Agent-to-Agent protocol channel plugin.

Distribution

  • Package: @openclaw/a2a
  • Install route: included in OpenClaw

Surface

channels: a2a