Packages

Elixir implementation of the Agent-to-Agent (A2A) protocol

Current section

Files

Jump to
a2a SPEC.md
Raw

SPEC.md

# Elixir A2A — Roadmap
Unimplemented work organized by category. Phases 1-7 (core types, agent
runtime, JSON codec, JSON-RPC, HTTP server, HTTP client, registry/supervisor)
and telemetry instrumentation are complete. See the codebase and README for
current functionality.
---
## TCK Compliance
CI runs `bin/tck all` against `test/tck/server_v1.exs`, pinned to a known
[A2A TCK](https://github.com/a2aproject/a2a-tck) revision (`TCK_REF` in
`bin/tck`). The suite targets **A2A v1.0 only** — upstream replaced its
category-based v0.3 suite with RFC 2119 requirement levels, so MUST failures
are hard, SHOULD failures are expected failures, and MAY tests skip when the
capability isn't declared.
Known failures are listed in
[`test/tck/expected-failures.txt`](test/tck/expected-failures.txt); `bin/tck`
compares the actual failure set against that baseline and fails only when they
differ, so both a new regression and a newly-fixed gap are surfaced. A gap is
closed by deleting its line in the same commit as the fix.
### Current Results
`bin/tck all` — 99 passed, 0 failed, 166 skipped. The skips are capability-
and transport-gated tests, not failures.
The TCK server declares `capabilities.streaming` (#84) and
`capabilities.pushNotifications` (#93), which is why those suites run at all.
The authenticated extended card is still undeclared, so that family continues
to skip — see below.
| Suite area | What it covers | Notes |
|------------|----------------|-------|
| **agent_card** | Card shape, extensions, caching headers | — |
| **core_operations** | Message send, task lifecycle, data model, error handling | — |
| **jsonrpc** | JSON-RPC 2.0 envelope, error codes, error info | — |
| **grpc** | gRPC transport binding | Skipped — transport not implemented |
| **http_json** | REST/HTTP+JSON binding | Skipped — transport not implemented |
### Known Gaps
None. `test/tck/expected-failures.txt` holds no entries — every test the suite
runs against us passes, so any new failure is a regression.
### Skipped (Expected)
| Tests | Reason | Unblocked by |
|-------|--------|--------------|
| Extended agent card | `supportsAuthenticatedExtendedCard` not declared | #100 |
| In-task authentication | Agent doesn't trigger `auth-required` state | Optional — agent-level decision |
| TLS / certificate validation | TCK server runs plain HTTP on localhost | Deploy-time concern, not library |
| `CORE-MULTI-005` context inference | Tasks get no `contextId` when the client sends none, so the test cannot run | #101 |
| gRPC / HTTP+JSON transports | Single transport (JSON-RPC only) | gRPC / REST Transport Bindings (below) |
| OAuth2 metadata URL | No OAuth2 scheme configured | Client-Side OAuth 2.0 Flows (below) |
### Roadmap: Feature → TCK Tests Unlocked
The test paths below predate upstream's restructure into `tests/compatibility/`
and have not been re-derived; treat them as intent, not literal paths.
| # | Feature | TCK tests enabled |
|---|---------|-------------------|
| 1 | **Authenticated Extended Card** (#100) | `mandatory/protocol/test_extended_agent_card.py`; `capabilities/` extended card tests |
| 3 | **gRPC Transport Binding** | `transport-equivalence` category (functional equivalence across transports) |
| 4 | **REST Transport Binding** | `transport-equivalence` category |
---
## Protocol
### Push Notifications
Config CRUD and webhook delivery are both implemented. The methods stay gated
on the declared capability, and a server that does not opt in returns
`-32003 PushNotificationNotSupportedError`:
```elixir
{A2A.Plug, agent: MyAgent, base_url: url,
agent_card_opts: [capabilities: %{push_notifications: true}]}
```
Implemented:
- `A2A.PushNotificationConfig` struct (id, task_id, url, token, authentication)
- Optional `A2A.TaskStore` callbacks `set_push_config/2`, `get_push_config/3`,
`list_push_configs/2` and `delete_push_config/3`, with `A2A.TaskStore.ETS`
keeping configs in a second table so they never reach the task read paths
- Optional `A2A.JSONRPC` handler callbacks, so a handler that implements none
of them keeps the old `-32003` behaviour
- `A2A.Client` functions for all four methods
- Configs are scoped to an existing task: registering one for an unknown task
returns `-32001 TaskNotFoundError`, and every operation runs through the
`:authorize_task` hook
- `configuration.taskPushNotificationConfig` honoured on `message/send`, so a
client can register a webhook on the initial send rather than through CRUD.
It runs the same `:authorize_task` hook under `:push_set` that the CRUD
method does, and delivers the task's current status once on registration —
a task that finishes in a single turn changed state before the webhook
existed, and the spec wants at least one delivery per configured webhook
- `A2A.PushNotificationSender` behaviour, with `A2A.PushNotificationSender.HTTP`
as the default when `:req` is available. Every task state change POSTs a
`StreamResponse` status update to each registered webhook, carrying the
config's credentials as an `Authorization` header
- Delivery runs in a spawned process, never the agent's, so a hanging webhook
cannot stall task processing. Each attempt is reported through
`[:a2a, :push_notification, :delivery]` telemetry
- Bounded retry with exponential backoff and a per-attempt timeout
Optional hardening on the HTTP sender, off by default because the spec makes
both a SHOULD and enabling them breaks local development and the compliance
suite's own `localhost` receiver:
- `:require_https` — reject plain-HTTP webhook URLs
- `:block_private_ips` — reject loopback, link-local and RFC 1918 hosts
Still open, and tracked separately since neither the spec nor the TCK requires
them and no consumer is driving a signature format yet:
- HMAC-SHA256 signature generation/verification on webhook payloads
- Replay protection via timestamp and nonce headers
- Constant-time signature comparison to avoid timing attacks
### Authenticated Extended Card
`agent/getAuthenticatedExtendedCard` currently returns `-32004
UnsupportedOperationError`. Full implementation requires:
- Optional `extended_card/1` callback on the Agent behaviour — receives
authenticated identity, returns an extended card with additional
skills/capabilities
- `A2A.Plug` serves it at the spec-defined endpoint, gated by auth middleware
- Public card advertises `supportsAuthenticatedExtendedCard: true`
- Enables per-client capability disclosure (two-tier card model)
### gRPC Transport Binding
A2A v0.3 defines gRPC as an alternative transport. Not started. Would require:
- Protobuf schema definitions mirroring the JSON wire format
- gRPC server module (parallel to `A2A.Plug`)
- `AgentInterface` / `TransportProtocol` support in agent cards
### REST Transport Binding
The A2A spec defines REST as a first-class transport alongside JSON-RPC. Our
library only supports JSON-RPC. Full implementation requires:
- REST endpoint definitions per the spec (`POST /message:send`,
`POST /message:stream`, `GET /tasks/{id}`, `POST /tasks/{id}:cancel`,
`GET /tasks`)
- An `A2A.Plug.REST` module (or fold into existing `A2A.Plug` with interface
routing based on the request path)
- `AgentInterface` / `supportedInterfaces` in the agent card to advertise
which transports are available (JSON-RPC, REST, gRPC)
### Version & Wire Format Negotiation
The server accepts both A2A v0.3 and v1.0 wire formats and method names,
and the `A2A-Version` header is parsed and validated per spec §3.6
(unsupported versions return `VersionNotSupportedError` -32009). Still
outstanding:
- Per-interface version selection from `supportedInterfaces[]` — agents
exposing multiple interfaces at different URLs with different versions
- Wire format option (`spec_json` vs `proto_json`) controlling field naming
conventions (camelCase vs snake_case)
- Query-parameter fallback (`?A2A-Version=…`) — spec §3.6.1 says clients
MAY use it instead of the header
### Task Resubscribe Streaming
`tasks/resubscribe` (`SubscribeToTask`) is implemented. Subscribing opens an
SSE stream whose first event is the task as it stands, followed by a status
update per state change, ending when the task reaches a terminal state. An
unknown task answers `-32001 TaskNotFoundError` and one that has already
finished answers `-32004 UnsupportedOperationError`; both are gated on the
declared `streaming` capability and run the `:authorize_task` hook under
`:resubscribe`.
Subscribers are held in the agent's own state — `AGENTS.md` rules out a
supervision tree, so there is nowhere else to keep them — and each is
monitored, so a dropped connection deregisters itself. `A2A.Plug`'s
`:resubscribe_timeout` (default 60s) closes a stream that goes idle, since a
task that never terminates would otherwise pin its connection process open.
Two deliberate limits:
- **Events before the subscription are not replayed.** A subscriber sees the
task snapshot and everything after it, not the artifacts already produced.
Pair with `tasks/get` for the full history.
- **The agent's own stream is never re-enumerated.** A task still holds its
source enumerable in `metadata[:stream]`, and enumerating it replays from
the start rather than attaching — which would duplicate the task's
artifacts and history. Resubscribe reads the stored task and waits for
pushed events instead.
---
## Agent Runtime
### `{:delegate, agent, msg}` Reply Type
Phase 8 — not started. First-class agent-to-agent forwarding from within
`handle_message/2`. The runtime would dispatch the message to the target agent
and relay the response back to the original caller transparently.
### Atomic Task Updates
The `A2A.TaskStore` behaviour only has `put/2` and `get/2`. An atomic
`update_task/3` callback that accepts a transformation function would enable
safe concurrent modifications:
- `update_task(store, task_id, fun)` — apply `fun` to the current task
atomically, returning `{:ok, updated_task}` or `{:error, reason}`
- `A2A.TaskStore.ETS` implementation using optimistic locking (separate lock
table, retry on conflict) to avoid serializing all updates through a
single process
---
## Discovery
### Multi-Agent Plug / Directory Endpoint
Currently each `A2A.Plug` mount exposes a single agent's card. A remote client
can't discover all agents from one endpoint. Options:
- JSON array at `/.well-known/agent-card.json` (non-standard but practical —
the spec doesn't prohibit it)
- Per-agent cards at `/.well-known/agents/{name}/agent-card.json`
- Query endpoint (e.g., `GET /agents?skill=finance`) — a step toward the
curated registry concept, though the spec doesn't prescribe an API yet
This is the most impactful discovery improvement — it connects the local
registry to the spec's Open Discovery mechanism.
### Client-Side Agent Cache (`A2A.Client.Registry`)
`A2A.Client.discover/2` fetches a remote card but doesn't store it — repeated
discovery hits the network every time. A client-side cache would:
- Store discovered `%AgentCard{}` structs
- Support `find_by_skill/2` across remote agents
- Enable patterns like: discover 10 agents at startup, route to the best one
based on skill tags at call time
### Registry Change Notifications
The current `A2A.Registry` is static after init. A `Phoenix.PubSub` or
`:pg`-based notification system would:
- Let Plug endpoints update when agents come and go
- Let distributed registries sync across nodes
### Standard Registry API
The A2A community is exploring standardizing registry interactions. If a
standard emerges, implement it as a Plug endpoint so agents can be registered
in external catalogs.
---
## Security
### Client-Side OAuth 2.0 Flows
`A2A.Client` already supports Bearer/API key auth via Req options:
```elixir
A2A.Client.new(card, headers: [{"authorization", "Bearer tok"}])
```
More structured support could include: reading `securitySchemes` from a
discovered card and prompting for credentials, or OAuth 2.0 Client Credentials
flow built into the client.
### Task-Level Access Control
The spec states: "Servers MUST NOT reveal the existence of resources the client
is not authorized to access." `A2A.Plug` now supports an `:authorize_task`
callback for task-scoped JSON-RPC operations.
- `tasks/get` and `tasks/cancel` call the callback before returning or mutating a
task. Denied requests return `TaskNotFoundError` so task IDs are not leaked.
- `tasks/list` filters the returned page through the same callback.
- The push notification config methods call it as `:push_set`, `:push_get`,
`:push_list` and `:push_delete`. They are distinct from `:get` so an
authorizer can grant read access to a task without also granting the ability
to rewrite the webhooks it delivers to.
- The callback receives `(operation, task, context)` where `operation` is
`:get`, `:cancel`, `:list`, `:push_set`, `:push_get`, `:push_list`, or
`:push_delete`, and `context.metadata` contains the resolved Plug metadata,
including `A2A.Plug.Auth` identity under `"a2a.auth"` when that plug is used.
Remaining hardening:
- Move authorization down into task stores that can filter before pagination.
- Add store-specific examples for tenant and user ownership policies.
### Agent Card Signature Verification
The spec allows agent cards to carry JWS signatures for authenticity
verification. Not yet implemented. Would require:
- `A2A.AgentCard.verify_signatures/2` — validate JWS signatures on a
decoded agent card against a caller-supplied verifier function
- `AgentCardSignature` struct for signature metadata (algorithm, key ID,
signature value)
- Verification is opt-in — callers choose whether to verify after decoding
- Depends on a JOSE library (e.g., `jose`) as an optional dependency
### Recommended Security Order
1. Agent card signature verification
2. Authenticated extended card endpoint
3. Client-side OAuth 2.0 flows
4. Store-level authorization filters
---
## Client
### Stream Cancellation
`A2A.Client.send_message_streaming/3` returns a `Stream` but provides no way
to explicitly cancel an in-flight SSE connection. A wrapper struct (similar to
a2a_ex's `A2A.Client.Stream`) would:
- Implement `Enumerable` for lazy consumption
- Expose `cancel/1` to abort the underlying HTTP connection
- Clean up resources on early termination
### SSE Reconnection with Backoff
Dropped SSE connections currently fail permanently. Production-grade streaming
needs:
- Exponential backoff on connection failures (configurable base/max/jitter)
- Resume from `last-event-id` header on reconnect so no events are lost
- Configurable max retry attempts before giving up
### Challenge-Response Auth
When a server returns `401` with auth challenge headers, the client should be
able to auto-retry with appropriate credentials:
- Parse `WWW-Authenticate` headers from 401 responses
- Invoke a caller-supplied auth callback to obtain credentials
- Retry the original request with the new credentials
- Integrates with the existing Req middleware pipeline
---
## Observability
### LiveDashboard Page (Optional)
An `A2A.DashboardPage` module implementing `Phoenix.LiveDashboard.PageBuilder`,
compiled only when `phoenix_live_dashboard` is available (same pattern as Oban).
Would show active agents, task counts, recent tasks with status/duration/errors,
and live state transitions.
---
## Usability
### `A2A.Server` Convenience Module
Wrap Bandit + Plug into a single startable child spec:
```elixir
{A2A.Server, agent: MyApp.InvoiceAgent, port: 4001}
```
### Additional TaskStore Backends
Current: `A2A.TaskStore.ETS` (single-node). Potential additions:
- `A2A.TaskStore.PG` — distributed via `:pg`
- `A2A.TaskStore.Redis` — requires `redix` optional dep
### Forward-Compatible Type Decoding
`A2A.JSON.decode/2` currently discards unrecognized JSON fields. To support
forward compatibility with newer spec versions:
- Preserve unknown fields in a `raw` map on decoded structs (or a dedicated
`_extra` field)
- Round-trip unknown fields through encode/decode so data isn't silently lost
- Enables interop with agents running newer spec versions that include
fields this library doesn't yet model
### Extension Metadata Handling
Implemented. See `A2A.Extension` for the behaviour, `A2A.Plug` and
`A2A.Client` for `A2A-Extensions` header negotiation, and
`A2A.Extension.Timestamp` for a reference profile-extension. Method
extensions (registering new RPC methods) and state-machine extensions
remain deferred until a concrete user emerges.
---
## Out of Scope
These are not planned for this library:
- **LLM integration** — use `instructor`, `langchain`, etc.
- **Tool/function calling** — use MCP via `hermes-mcp`
- **Agent reasoning, planning, or memory** — application-level concerns
- **UI rendering** — A2UI is a separate spec