a2a-adapter
Wrap a local CLI coding agent — Claude Code, Codex — as a standard
A2A v1.0.1 server. The agent you already run and
log into becomes a plain A2A endpoint that any A2A client or management system
can drive: send turns, stream the work, answer tool-approval prompts, cancel a
run, get webhook callbacks, reconnect after a drop.
It is both a Go library and a thin CLI. One process serves one agent as
one A2A endpoint — nothing more.
Scope
What this is, and deliberately what it is not:
- Is: a faithful adapter. A driver turns a CLI agent's event stream into A2A
task/artifact/status events; the protocol layer is the official
a2aproject/a2a-go. Session continuity (multi-turn resume within one A2A
context) is handled here because it is A2A semantics.
- Is not: NAT traversal, multi-tenant routing, or a multi-agent daemon.
Standard TLS/mTLS is built in and advertised as ordinary
https://; the
transport seam (Listen/DialContext/Identify) also allows a deployment or
management layer to supply opaque tunnels or custom routing (tsnet, a relay,
...). Serving many
agents on one host is many serve processes under systemd/launchd or a
manager, not something this binary does.
This keeps the adapter composable: a manager builds a fleet by orchestrating
these units (or by importing the library), while each unit stays a pure,
standard A2A agent.
The latest stable A2A specification is normative. The currently verified
baseline is A2A v1.0.1 (wire protocol 1.0), pinned in the
compliance matrix; adapter SPI convenience never
overrides a standard method, state, Part, error, security rule, or Extension
negotiation rule. Internal implementations are replaceable at documented seams,
while protocol semantics remain owned by the Endpoint.
Requirements
- Go 1.25.12 or newer. With the default
GOTOOLCHAIN=auto, an older Go
launcher can download the required toolchain.
- For
claude: the Claude Code CLI, logged in.
- For
codex: the Codex CLI. The recorded
compatibility baseline is 0.144.4. Remote approvals require the modern
item-scoped app-server requests which carry a turn ID; uncorrelated legacy
execCommandApproval / applyPatchApproval callbacks are denied and retire
the session rather than being attributed to a possibly newer turn.
Each side uses its own CLI login; the adapter never handles or repackages
another party's credentials.
Install
Install a tagged source release with the standard Go module toolchain:
VERSION=v1.0.0 # replace with the release tag you selected
go install github.com/vibe-agi/a2a-adapter/cmd/a2a-adapter@${VERSION}
a2a-adapter --version
The initial v1 distribution is source/Go-module based; CI verifies native
builds on Linux, macOS, and Windows, but the project does not publish separate
binary archives.
Build
go build -o bin/a2a-adapter ./cmd/a2a-adapter
Quick start
Serve the built-in echo agent (no external binary needed) and inspect its
standard Agent Card:
bin/a2a-adapter serve echo --addr 127.0.0.1:9001
curl http://127.0.0.1:9001/.well-known/agent-card.json
Serve a real agent:
bin/a2a-adapter serve claude --workdir /path/to/project
bin/a2a-adapter serve codex --workdir /path/to/project
The AgentCard is published at /.well-known/agent-card.json; any standard A2A
client can resolve it and send a message.
Agents
| Name |
Backing CLI |
Notes |
echo |
none (in-process) |
Skeleton/self-test agent; echoes input. |
claude |
Claude Code, via the in-tree adapter compatibility package based on roasbeef/claude-agent-sdk-go |
Long-lived stream-json control protocol; tool approvals via can_use_tool; library wrappers may enable JSON-schema output with claude.WithOutputSchema. |
codex |
Codex, via pmenglund/codex-sdk-go |
Long-lived codex app-server; approvals via the app-server's server→client RPCs. |
A2A capabilities
- Streaming — A2A 1.0
SendStreamingMessage yields the turn as it unfolds (working →
artifact → completed) over SSE.
- Negotiated structured agent events — callers can opt in through the
standard A2A extension mechanism to receive versioned tool activity, file
changes, cumulative usage, structured output, and a closed terminal-failure
class. Durable events are ordinary artifact
DataParts; streamed events use
status metadata, whose latest envelope may remain in Task.Metadata. Stock
clients that do not request the extension keep the core A2A behavior
unchanged. Original agent.Thinking content and high-volume tool-output
deltas are hidden by default; see the
agent-events v1 contract. Embedders can
register other bounded implementations through the
common extension framework.
- Tool approvals — a gated tool surfaces as an
auth-required task. When
the caller activates the advertised tool-approval extension, the task also
carries the structured request and accepts a structured approval decision.
Without that negotiation, the text is explanatory only and authorization must
be resolved out-of-band; plain text is never treated as a credential. Both
drivers gate every tool, Bash included.
- Cancel — A2A 1.0
CancelTask interrupts the in-flight turn for real: the agent
subprocess is interrupted and the session dropped, not just the task status
flipped.
- Push notifications — register a webhook (in the send config or through
CreateTaskPushNotificationConfig) and the server POSTs every task update,
terminal included — so a manager learns a turn finished without holding an SSE
stream open. On by default; under the default guarded policy, the sender
accepts only callbacks which pass its public-network URL policy both when the
config is saved and when the connection is actually dialed.
- Resubscribe — A2A 1.0
SubscribeToTask reconnects to an in-flight task and
delivers the current snapshot plus live updates. (It does not replay the
discrete SSE events missed while disconnected; a finished task uses
GetTask.)
- Task and session persistence —
--store <file.db> keeps A2A tasks in
SQLite (pure-Go, no CGO) across restarts and always requires a stable,
non-secret --workspace-id. For the built-in Claude and Codex drivers, the
same store also persists A2A context resume checkpoints, so a later adapter
process can continue the agent context for that workspace and conversation. Echo and
third-party drivers do not get durable resume automatically. On startup, the
built-in SQL store atomically marks tasks abandoned in a nonterminal state as
failed, so a hard restart cannot leave false working tasks behind. Any
taskstore.Store can be injected instead by library users.
Configuration
Precedence, highest first: command-line flag → A2A_ADAPTER_* env var → config
file (a2a-adapter.yaml, .yml, .toml, or .json in the current directory
or ~/.config/a2a-adapter/) → default.
See a2a-adapter.example.yaml.
A2A_ADAPTER_ENV accepts comma-separated KEY=VALUE pairs; individual --env
values override matching keys from it and from the config file.
serve flags:
| Flag |
Default |
Purpose |
--addr |
127.0.0.1:9001 |
Listen address. |
--advertise |
|
Externally-reachable base URL for the card (needed when binding a wildcard address). |
--trusted-tls-proxy |
false |
Explicitly trust an upstream TLS terminator for an HTTPS --advertise URL; the backend stays plaintext and must not be directly reachable. |
--store |
|
SQLite file for A2A task persistence plus supported-driver resume checkpoints (empty = in-memory); requires --workspace-id. |
--no-push |
false |
Disable push notifications. |
--allow-peer |
|
Restrict callers to these transport peer identities (repeatable); requires an identifying transport. |
--tls-cert / --tls-key |
|
Server certificate/key — switches to the encrypted transport. |
--tls-client-ca |
|
CA to verify client certs (mutual TLS; required for --allow-peer to name callers). |
--model |
|
Model override. Claude: empty uses the SDK's pinned default (Sonnet 4.5), not your CLI default; Codex: empty uses the CLI default. |
--workdir |
|
Agent working directory. |
--workspace-id |
|
Stable, non-secret workspace identity used for session continuity; required with --store. |
--continuity-id |
default-v1 |
Non-secret resume-policy epoch. Change it when credentials, backend, or resume policy changes so incompatible sessions are not resumed. |
--shared-principal |
|
Explicit owner for anonymous A2A callers on a single-trust endpoint; every caller shares the same tasks and sessions. |
--permission-mode |
|
default, plan, acceptEdits, bypassPermissions. |
--shutdown-timeout |
30s |
Graceful pipeline drain after SIGTERM (0 = hard stop); final driver teardown has its own bounded window. |
--env |
|
KEY=VALUE injected into the agent subprocess (repeatable). |
Security
The A2A endpoint fails closed at the session-ownership boundary. Anonymous
loopback requests receive the built-in local principal. When binding it to a
non-loopback address, startup requires either authenticated clients via
--tls-client-ca or an explicit --shared-principal; a server certificate by
itself encrypts traffic but does not identify callers. For distinct caller
identities, use mutual TLS and gate on them:
a2a-adapter serve claude \
--tls-cert server.pem --tls-key server-key.pem \
--tls-client-ca clients-ca.pem \
--allow-peer manager-1 --allow-peer manager-2
--tls-client-ca turns on mutual TLS (every caller must present a cert signed by
that CA); --allow-peer then restricts service to the listed certificate common
names. Authorization fails closed — a caller the transport can't identify, or
one not on the list, gets 403. An
allowlist without an identifying transport is refused at startup rather than
silently denying everyone.
--shared-principal <name> is the deliberate single-trust-domain escape hatch
for an endpoint whose surrounding network or reverse proxy already supplies
the trust boundary but not an identity that the adapter can consume. It assigns
every anonymous caller the same owner, so those callers share task visibility,
session continuity, and resume checkpoints. Do not use it to separate mutually
untrusted clients.
TLS is standard: with --tls-cert/--tls-key the transport serves ordinary
TLS and advertises an https:// card, so any stock A2A client reaches it with a
normal TLSClientConfig — no custom dialer. Adding --tls-client-ca requires
verified client certificates (mutual TLS); the card then declares the standard
mutualTLS security scheme and the certificate CommonName becomes the caller
identity for --allow-peer.
Library embedders must serve a securetls endpoint on the listener returned by
that transport; mounting it on a plain listener fails closed. A custom transport
advertising https:// likewise defaults to requiring direct TLS. Only a trusted
reverse-proxy transport that intentionally terminates TLS upstream should
implement transport.TLSRequirement and return false; a transport declaring
client-certificate authentication must use direct TLS.
The built-in plain transport requires that decision explicitly:
--advertise https://... without direct TLS is rejected unless
--trusted-tls-proxy is set (library: local.WithTrustedTLSProxy()). That flag
only describes a protected TLS-termination hop; it neither authenticates callers
nor trusts forwarding headers, and untrusted clients must not be able to reach
the plaintext backend listener.
The negotiated agent-events extension is not an automatic sanitizer. Tool
names/input/output, file paths and diffs, and structured output can contain
source code or secrets. Only offer it to callers inside the endpoint's trust
boundary, or replace the default with a redacting
server.WithA2AEventProjectionPolicy. The original text of an agent.Thinking
event is never serialized through this A2A extension; a wrapper must
deliberately provide a safe summary. The default policy does not semantically
inspect agent.Raw or tool/file payloads, so drivers and wrappers must not
disguise private reasoning inside those event types.
Durable event parts and the latest streamed-status envelope enter the task's
storage/push trust boundary; extension negotiation does not filter a later
authorized GetTask response.
Terminal failure metadata is the exception to caller-owned projection:
turn.failure contains only a closed agent.FailureKind value and bypasses
EventProjectionPolicy. Drivers must derive it only from structured upstream
fields or local protocol state, never provider/assistant/tool text or
error.Error(); an unknown value becomes generic before storage.
The complete policy and wire contract is
documented here.
The default push implementation treats every callback URL as untrusted. It
validates the URL and its DNS result before acknowledging or persisting a push
config, repeats the check at delivery, refuses redirects, and validates the
resolved IP used by the actual dial. This two-stage policy also wraps a
caller-supplied push config store. WithPushNetworkPolicy replaces only the
authoritative URL/DNS/dial decision; WithPushRequestPreparer replaces only
authentication/header preparation. Core still owns the HTTP method, standard
media type and payload, redirect refusal, protected headers, timeout, and the
composition boundary, so replacing request signing cannot silently turn off
SSRF enforcement.
This is a single-agent endpoint. Every task-addressed operation—including get,
list, continuation, cancel, subscribe, referenced tasks, and push-config
CRUD—is authorized against the same authenticated owner before task access.
The built-in task authorizer deliberately makes foreign and nonexistent IDs
indistinguishable. Organization-specific roles or policy can be injected with
WithTaskAuthorizer; multi-agent routing and a tenant control plane remain out
of scope.
Remote-facing defaults are bounded: 64 accepted HTTP connections, 4 concurrent
large buffered request bodies (32 MiB each, held only through JSON decode), 32
active HTTP handlers split into 24 work and 8 control slots, 16 agent executions,
and 64 resident Registry sessions (10-minute idle TTL).
Every HTTP request also passes a bounded process-local admission bucket keyed by
verified transport peer or canonical socket IP before body work; every decoded
A2A operation then passes one aggregate bucket keyed by its authenticated
principal when available. The defaults are a 256-request/4-per-second source
bucket, a 512-request/8-per-second principal bucket, at most 4096 retained
subjects, and ten-minute idle reclamation. Forwarding headers are never trusted.
HTTP admission returns 429 plus Retry-After; JSON-RPC overload returns ServerError with
standard RetryInfo (and unary responses also carry the header; an already
flushed SSE response uses the in-band detail). This is a safe single-process abuse
baseline, not a distributed quota: multi-replica wrappers can inject their own
aggregate server.RateLimitPolicy, whose ownership and public harness are
documented in the embedding API policy.
Compact bodies up to 64 KiB bypass the large-buffer slots, so image uploads
cannot starve card discovery or cancellation; a separate 64-prefix admission
cap bounds their aggregate memory even over multiplexed HTTP/2. Request
headers/body inactivity, response writes, TLS handshakes, session startup, and
silent turns also have size/time limits.
A progressing upload may run for up to five minutes; its rolling 30-second idle
deadline still rejects a stalled peer sooner. A session holding a pending tool
approval is pinned across the idle TTL, turn- and generation-bound, and retired after 30 minutes
unless it is answered or canceled. Library users can override these policies
through server options and sessionregistry.Config.
The CLI gives detached A2A execution pipelines 30 seconds to finish after
SIGTERM before hard-aborting them; this includes task persistence and synchronous
push delivery, not merely the HTTP handler.
Library
The CLI is a thin wrapper; the same pieces compose directly. The public
stability, ownership, shutdown, and conformance-harness rules are specified in
docs/embedding-api-policy.md.
import (
"context"
"github.com/vibe-agi/a2a-adapter/pkg/drivers/claude"
"github.com/vibe-agi/a2a-adapter/pkg/server"
"github.com/vibe-agi/a2a-adapter/pkg/sessionregistry"
"github.com/vibe-agi/a2a-adapter/pkg/transport/local"
)
tr := local.New("127.0.0.1:9001")
drv := claude.New()
registration, err := sessionregistry.NewRegistration(sessionregistry.RegistrationSpec{
NamespaceID: "my-wrapper",
RegistrationID: "primary-agent",
ContinuityID: "policy-v1", // rotate when credentials/resume policy changes
Driver: drv,
Workspace: sessionregistry.WorkspaceSpec{
ID: "project-1", Path: "/path/to/project",
},
})
if err != nil { return err }
registry, err := sessionregistry.New(registration, sessionregistry.Config{})
if err != nil { return err }
defer func() { _ = registry.Close(context.Background()) }()
err = server.Serve(ctx, tr, drv,
server.WithSessionRegistry(registry),
server.WithTaskStore(store), // optional: persist tasks
)
For a wrapper that already owns an HTTP server, mux, or shared port, assemble an
Endpoint instead of copying Serve internals:
import (
"context"
"errors"
"net"
"net/http"
"time"
)
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { return err }
tr := local.NewWithListener(raw)
ln, err := tr.Listen(ctx) // caller drives the transport lifecycle
if err != nil { return err }
defer tr.Close()
endpoint, err := server.NewEndpoint(ctx, tr, drv,
server.WithSessionRegistry(registry),
server.WithTaskStore(store),
)
if err != nil { return err }
defer endpoint.Close() // hard-abort fallback; prefer Shutdown below
routes := endpoint.Routes()
mux := http.NewServeMux()
mux.Handle(routes.JSONRPCPath, endpoint)
for _, route := range routes.AgentCardPaths {
mux.Handle(route, endpoint)
}
mux.Handle("/my-wrapper/healthz", healthHandler)
host := &http.Server{Handler: mux}
serveListener, err := endpoint.PrepareHTTPServer(host, ln)
if err != nil { return err }
if _, err := endpoint.ReconcileStartup(ctx); err != nil { return err }
serveErr := make(chan error, 1)
go func() { serveErr <- host.Serve(serveListener) }()
select {
case <-ctx.Done(): // the wrapper's process/lifecycle signal
case err := <-serveErr:
if err != nil && !errors.Is(err, http.ErrServerClosed) { return err }
}
// During shutdown, stop this endpoint and wait for detached Execute/Cancel,
// task-store updates, and synchronous push delivery. The shared host and
// transport remain caller-owned.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := endpoint.Shutdown(shutdownCtx); err != nil { return err }
NewEndpoint never calls Transport.Listen or Transport.Close, never closes a
caller-supplied SessionRegistry, task/push store, custom push sender, or host
server, and does not mutate durable tasks during construction. The activation
gate returns 503 until ReconcileStartup succeeds. Routes reports the exact
JSON-RPC and AgentCard mount paths; PrepareHTTPServer composes connection
identity, timeouts, header limits, and accepted-connection admission with the
host's existing hooks. Shutdown closes endpoint admission and drains the full
a2a-go execution pipeline; Close is the explicit hard-abort path.
Endpoint.Snapshot() exposes a bounded, payload-free view of activation,
readiness, serving/draining state, in-flight HTTP requests and executions, the
startup reconciliation count, and hard-abort state. sessionregistry.Registry.Stats()
similarly reports aggregate session phases, attachments/waiters, active turns,
pending approvals, ended generations, and replay-budget use without exposing
conversation keys, principals, workspaces, tokens, approvals, or event data.
These are inputs for a caller-owned health/readiness handler, metrics exporter,
or tracing integration; the adapter does not install such a product or choose a
telemetry backend.
Operational records emitted through server.WithLogger pass through the
adapter's sensitive-key boundary, including a2a-go records and net/http panic
output. The logger handler/sink and attributes bound before WithLogger remain
caller-owned: Go's slog.Handler cannot expose those pre-bound values for the
adapter to inspect, so wrappers must pass a bare logger or pre-bind only
reviewed, non-sensitive correlation data. Attributes attached after the
boundary under credential, payload, error, panic, or stack keys are redacted.
Codex's optional codex.WithSensitiveWireTranscript is deliberately outside
operational logging. It writes the complete, unredacted native JSON-RPC stream,
which can contain prompts, assistant output, tool arguments/results, file paths
and contents, and protocol-carried authentication material. It is disabled by
default. Calls from both directions and all sessions opened by the Driver are
serialized, but writes are synchronous and best effort; a blocking sink can
stall Codex. A wrapper enabling it must therefore use a promptly returning,
access-controlled, appropriately encrypted sink with an explicit
retention/deletion policy; never route it to a shared application log.
Wrapper policy seams include WithAuthenticationProvider,
WithTaskAuthorizer, WithCallInterceptors, WithPushNetworkPolicy,
WithPushRequestPreparer, and WithAgentCardTransform. The authenticated A2A
user name is the single owner identity for both task storage and Registry
sessions; core does not expose a second resolver that could split those scopes.
The built-in interface does not advertise AgentInterface.tenant. A future
tenant-aware wrapper must add card declaration, authenticated routing, task
ownership, and Registry scoping atomically rather than deriving identity from
request metadata. Push network policy and request preparation are separate
decorators, so replacing request signing never disables dial-time SSRF checks.
Execution/resource seams include WithWorkspaceCoordinator,
WithWorkspaceLeaseProvider, WithExecutionPolicy,
WithSessionEnvironmentProvider, WithFileReferencePolicy,
WithA2AProjectionLimits, and WithLifecycleObserver. Static workspace,
process-local exclusive leasing, bounded execution admission, deny-by-default
URL references, hard projection limits, and a no-op observer are built in; a
wrapper can replace only the policy or backend it disagrees with. Durable push
uses one WithDurablePushBackend aggregate for the task/config/outbox
transaction, while sender, retry, outbound-network, and request-preparation
policies remain separately composable. The aggregate boundary is intentional:
splitting those persistence pieces would reintroduce crash and generation/ABA
windows. Core calls the aggregate's CreateWithPush, UpdateWithPush, and
ReconcileNonTerminalWithPush methods with the full authoritative Task
mutation plus a separately constructed opaque pushdelivery.OutboundEvent;
implementations commit the mutation and every corresponding outbox row in one
transaction and must enqueue exactly that opaque event. A pushed Task
deliberately omits its optional history field before either an immediate
callback or durable outbox write, while the authoritative Task retains its full
history. Normal Send/SSE/Get/List response projections are unchanged:
historyLength controls those responses, not webhook payloads. This is a
specific data-minimization boundary, not a general secret scrubber; standard
Message, Artifact, status.message, and metadata payloads remain visible to
the configured webhook.
local has no authenticated peer identity, so it cannot be combined with
WithAllowedPeers. Use securetls with verified client certificates (or
another identifying transport) before enabling an identity allowlist.
A driver implements agent.Driver/agent.Session (a token-correlated,
channel-based event pump with SendTurn/TurnEvents/Close, plus optional
turn-control interfaces); the transport implements
transport.Transport; the task store implements a2a-go's taskstore.Store. All
are injection points — the core only calls the interfaces. SessionRegistry is
the sole session owner and event consumer behind the standard A2A projection; its
immutable Registration is where a wrapper pins workspace, driver profile,
continuity epoch, and an optional per-principal secret environment provider.
A registration Profile carries the portable access level, model, and bounded
driver-specific settings. Its portable shape is validated first; any non-empty
profile also requires the driver to implement the optional, side-effect-free
agent.ProfileValidator. Unsupported or unknown policy fails before the
Registration becomes active and is checked again before Driver.Open, rather
than being silently ignored.
Third-party driver authors should run the public pkg/agent/agenttest contract
harness against a fresh, hermetic scripted backend:
func TestDriverContract(t *testing.T) {
agenttest.Harness{
New: func(t testing.TB) agenttest.Fixture {
return agenttest.Fixture{
Driver: newHermeticDriver(t), // no installed agent CLI
Options: agent.SessionOptions{Workspace: t.TempDir()},
Scenarios: agenttest.Scenarios{
Send: agent.NewTextTurn("scripted normal turn"),
},
}
},
}.Run(t)
}
The harness checks portable admission and lifecycle behavior plus scripted
Send/Done traces, stale-turn fencing, interrupt, approval, elicitation,
steering, resume, and usage accounting. A deterministic scenario is required
for every capability the manifest advertises. Harness.New itself must return
promptly, and driver-owned asynchronous goroutines must contain their own
panics; an in-process harness can isolate only calls made through the portable
Driver/Session boundary.
agent.Done carries no provider diagnostic. A failed turn uses the closed
agent.FailureKind vocabulary documented in the
Driver SDK contract; empty or unprovable failures are
generic. Fixed A2A status wording remains available to every client, while
the exact safe class is exposed only through negotiated agent-events v1
turn.failure metadata.
Manifest.InputModes and Manifest.OutputModes are executable MIME contracts,
not card-only documentation. The server rejects inbound parts outside the
advertised input modes and rejects a request whose acceptedOutputModes has no
compatible mode. Every admitted standard A2A text/raw/data/URL part is preserved
in order as the sole content representation, agent.Turn.Parts; there are no
parallel text/image fields to reconcile, and adapter core never fetches URL
references.
A driver can emit agent.ArtifactPart for a standard user-facing output part;
the bridge validates its media type against the registered output modes before
projecting it into a task artifact.
A task store
may additionally implement store.StartupReconciler; Serve supplies the
default failed-on-restart policy, while wrappers can replace it with
server.WithStartupReconciliation or take full ownership with
server.WithoutStartupReconciliation. Automatic reconciliation assumes one
live endpoint incarnation owns the store; wrappers sharing a backend across
endpoints must scope or lease that ownership and disable the default policy.
server.Serve advertises and runs the optional structured-agent-event
projection by default. server.WithA2AEventProjectionPolicy lets a wrapper
redact or select events, and a nil policy disables both projection and the card
declaration. server.NewEndpoint atomically aligns that declaration,
per-request negotiation, output modes, and Registry-backed projection; there is
no supported Driver-direct executor assembly. See the
v1 extension contract for negotiation,
carriers, schema, decoder helpers, and persistence semantics. Durable events
enter task artifacts; streamed status metadata retains its latest envelope in
task metadata, so wrappers must treat both paths as stored/push-visible data.
Other extension implementations use the same immutable declaration,
exact-version negotiation, codec/input/projection, and hard-limit contracts in
pkg/extension; extension code cannot
replace the endpoint's A2A 1.0 negotiation rules.
Development
go test ./... # unit + in-process round-trip tests
go test -race ./...
go vet ./...
The container interoperability suite is a nested module so Testcontainers and
Docker dependencies do not enter the adapter module. Ordinary CI runs its
Docker-free contracts with scripts/run-agentmatrix.sh. The dedicated
pull-request/push/scheduled/manual scripts/run-agentmatrix-real.sh gate builds
an immutable image and selects the applicable C01-C24 protocol, lifecycle,
recovery, provider-fault, concurrency, delivery-commit, and hygiene cases plus
the ordered A2A cycle Claude → Codex → OpenCode → Claude. It runs real pinned
Claude 2.1.217, Codex 0.144.4, and OpenCode 1.18.4 CLIs against
provider-specific deterministic model servers. On 2026-07-26 the expanded
C09-C24 fresh-image matrix passed on
sha256:3306e9e6b983238fa31134f620cdd54dac029065ddb46b239e7a5ea6d89a8e7a
(go test: 946.235s), including verified C24 aggregate hygiene. See
integration/agentmatrix/README.md for
the isolation, causality, redaction, and optional live-model boundaries. The
Agent CLIs are real, but their model backends are deterministic scripted
servers. An optional live-model gate is not implemented and is not a
deterministic release blocker.
The pinned official 1.0.0.alpha2 TCK raw report is 91 passed, 2 failed, and
172 skipped. The two failures are locked, reviewed alpha2 known issues accepted
by the repository's exact known-issues gate; the raw result is not represented
as an official TCK pass.
Real-agent smoke tests are gated behind env vars (A2A_TEST_CLAUDE=1,
A2A_TEST_CODEX=1) so the default suite needs no external binary. Offline
coverage differs per driver: codex's wire handling has unit tests over
hand-written app-server notifications; the claude driver (whose wire parsing
lives in the SDK) has lifecycle tests over an injectable stream — stream death,
interrupt deadlines, duplicate-terminal suppression. Recorded, sanitized wire
fixtures for the real CLI shapes (claude 2.1.211, codex 0.144.4) are committed
under each driver's testdata/real/ and replayed offline through the projection
layer; a CI gate scans them for secrets. Separate synthetic bidirectional
captures replay the approval/control path through the real SDK transports, and
bridge tests pin agent.Event → A2A projection across JSON, protobuf, streaming,
unary, and stored-task paths.
Linting uses golangci-lint v2 (the repo .golangci.yml is v2 format; a v1
binary refuses it):
golangci-lint run ./...
Release builds inject the version and strip local paths (the || echo dev
fallback matters: in a source tarball git describe yields nothing, and an
empty version would otherwise be injected):
go build -trimpath \
-ldflags "-X github.com/vibe-agi/a2a-adapter/internal/version.Version=$(git describe --tags --always 2>/dev/null || echo dev)" \
-o bin/a2a-adapter ./cmd/a2a-adapter
bin/a2a-adapter --version
Claude SDK compatibility package
Claude image input and the adapter's approval/interrupt lifecycle need SDK
behavior that upstream roasbeef/claude-agent-sdk-go does not yet expose.
internal/third_party/claude-agent-sdk-go is therefore a source-compatible package kept
inside the root module, rather than a machine-local replacement or an
unpublished nested module. Its exact upstream base, retained patches, and removal criteria
are recorded in
FORK.md.