Problem Statement
Adding a new transport to the A2A CLI today requires modifying four tightly-coupled locations and recompiling:
internal/flagparse/transports.go:54-67 — add alias to parseTransport() switch
internal/cli/root.go:47-70 — add transport-specific config fields to globalConfig + register new flags (lines 133-138)
internal/cli/client.go:119-143 — wire the new transport's a2aclient.FactoryOption into clientFactoryOpts()
- A dedicated file (e.g.
slim.go + slim_nocgo.go) — the transport implementation itself, possibly behind build tags
The SLIMRPC transport demonstrates this pain: it requires CGO, adds 6 global flags, needs conditional compilation, and anyone wanting to use it must rebuild from source with the right build tags. This model doesn't scale for the growing ecosystem of transport protocols that community and enterprise users need (WebSocket, SLIM variants, proprietary message buses, etc.).
A plugin system would allow third parties to ship transport implementations as standalone binaries without forking or recompiling the CLI.
Goals
- Users install a transport plugin by placing a binary on PATH — no CLI recompile required
--transport <name> works uniformly for built-in transports and plugins
- Plugin authors can implement transports in any language (at minimum Go via go-plugin, and any language that can implement a CLI subcommand contract)
- Plugin-specific configuration (flags or env vars) can be declared and passed through
- Crash isolation — a misbehaving plugin does not corrupt the host CLI process
- Existing built-in transports (rest, jsonrpc, grpc, slimrpc) continue to work unchanged
Non-Goals
- Server-side plugins (
a2asrv.RequestHandler extensions) — this proposal covers client transports only
- Plugin distribution / registry — users install binaries themselves (like kubectl plugins)
- Hot-reloading or plugin auto-update
- Replacing the build-tag mechanism for existing optional transports (SLIMRPC stays compiled-in)
Discovery Mechanism
Follow the kubectl plugin convention:
- Naming:
a2acli-transport-<name> (e.g. a2acli-transport-websocket, a2acli-transport-slim-v2)
- Discovery: search
$PATH when --transport <name> does not match a built-in
- Validation: run a handshake/capabilities check before delegating
Resolution order in parseTransport():
- Match built-in aliases: rest, jsonrpc, grpc, slimrpc
- Search PATH for
a2acli-transport-<name>
- Found → validate + use; not found → error listing available plugins (like kubectl does)
A new a2a transport list subcommand (or a2a plugin list) should scan PATH and report discovered plugin binaries with their declared metadata (version, supported protocol version, description).
Approach A: hashicorp/go-plugin (gRPC IPC)
The host CLI launches the plugin binary as a subprocess and communicates over a local gRPC channel using hashicorp/go-plugin.
Plugin contract: a protobuf service matching the a2aclient.Transport interface (12 methods) plus Handshake and DeclareFlags methods.
The host constructs a TransportFactory that:
- Launches
a2acli-transport-<name> via go-plugin
- Performs a versioned handshake (protocol version negotiation built into go-plugin)
- Returns a
Transport adapter proxying each method call over gRPC to the plugin
This adapter is registered as an a2aclient.FactoryOption and slots into clientFactoryOpts() exactly like built-in transports.
Pros:
- Typed contract via protobuf — compile-time errors for plugin authors using Go
- Versioned handshake with automatic negotiation (go-plugin built-in)
- Full crash isolation — plugin crashes produce a clean error, not a CLI segfault
- Efficient for streaming methods (
SendStreamingMessage, SubscribeToTask) via multiplexed gRPC
- Cross-language: any language with a gRPC implementation can author plugins
Cons:
- Plugin authors must depend on the go-plugin SDK or implement the gRPC handshake manually
- Heavier startup cost — spawns process + establishes gRPC channel
- The protobuf service definition must be kept in sync with the
a2aclient.Transport interface
- Adds
hashicorp/go-plugin as a new dependency (gRPC is already present for the built-in gRPC transport)
Approach B: Subcommand + JSONL (subprocess per call)
Each transport operation maps to a subcommand of the plugin binary. The host spawns the process, passes inputs as CLI arguments and/or stdin JSON, and reads JSONL output from stdout. No long-lived process or bespoke IPC protocol — just a binary that behaves like a well-specified CLI tool.
Subcommand mapping:
| A2A operation |
Plugin invocation |
SendMessage |
a2acli-transport-websocket send-message <params-as-json> |
SendStreamingMessage |
a2acli-transport-websocket send-message-stream <params-as-json> |
GetTask |
a2acli-transport-websocket get-task <params-as-json> |
CancelTask |
a2acli-transport-websocket cancel-task <params-as-json> |
ListTasks |
a2acli-transport-websocket list-tasks <params-as-json> |
SubscribeToTask |
a2acli-transport-websocket subscribe-task <params-as-json> |
GetExtendedAgentCard |
a2acli-transport-websocket get-agent-card <params-as-json> |
| capabilities query |
a2acli-transport-websocket info |
Output format:
- Non-streaming subcommands: single JSON object on stdout, exit 0 on success, non-zero + JSON error on failure
- Streaming subcommands (e.g.
send-message-stream, subscribe-task): one JSONL event per line until stream ends, then process exits
Example:
$ a2acli-transport-websocket send-message '{"endpoint":"ws://agents.example.com/weather","message":{"role":"user","parts":[{"text":"hello"}]}}'
{"task":{"id":"t-123","status":{"state":"completed"},"result":{...}}}
$ a2acli-transport-websocket send-message-stream '{"endpoint":"ws://agents.example.com/weather","message":{...}}'
{"type":"status","status":{"state":"working"}}
{"type":"artifact","artifact":{"parts":[{"text":"The weather is"}]}}
{"type":"artifact","artifact":{"parts":[{"text":" sunny."}],"lastChunk":true}}
{"type":"status","status":{"state":"completed"}}
info subcommand returns plugin metadata and supported subcommands — used by a2a transport list and for version compatibility checks:
$ a2acli-transport-websocket info
{"name":"websocket","version":"1.0.0","protocol_version":"0.4.0","description":"WebSocket transport for A2A CLI","env":[{"name":"A2A_TRANSPORT_WEBSOCKET_TLS_SKIP_VERIFY","default":"false","usage":"Skip TLS certificate verification"}]}
Same FactoryOption integration as Approach A — host wraps subcommand invocation behind a Transport adapter.
Pros:
- Language-agnostic — any language that can exec as a CLI (Python, Node, Rust, shell scripts)
- No SDK dependency; plugin is just a CLI tool with a defined subcommand contract
- Trivially testable — call subcommands directly from a shell
- Crash isolation per call — process exits after each operation; no stale state
- Simpler streaming model — process writes lines until done, then exits; no framing protocol needed
Cons:
- Process spawn overhead per call (acceptable for CLI use; not for high-frequency operations)
- Config passed via env vars only (no flag integration with host cobra tree)
- Streaming requires the host to keep the process alive and read lines until EOF — needs careful timeout and error handling
- No multiplexing — one concurrent operation per plugin process
Integration Points (both approaches)
| File |
Change |
internal/flagparse/transports.go |
parseTransport() falls through to plugin lookup for unknown transport names |
internal/cli/root.go |
New globalConfig field for plugin pass-through args |
internal/cli/client.go |
clientFactoryOpts() appends the plugin's FactoryOption when a plugin transport is active |
internal/cli/plugin.go (new) |
Plugin discovery, lifecycle management, Transport adapter |
internal/cli/plugin_transport.go (new) |
TransportFactory implementation proxying to the plugin process |
Flag passthrough options
- Two-phase parse: First pass identifies
--transport <name>, discovers plugin, queries its flag declarations, then re-parses with those flags registered on cobra. Full --help integration, but complex.
- Opaque passthrough: Flags prefixed
--<transport>-* or after -- are passed as raw key-value pairs to the plugin. Simpler, weaker help/validation UX.
- Env vars only: Plugin reads
A2A_TRANSPORT_<NAME>_*. Aligns with the existing .env/clicfg pattern in internal/clicfg/binder.go. No flag plumbing needed.
Open Questions
- Preferred approach? go-plugin/gRPC gives stronger typing and a long-lived connection; subcommand/JSONL is simpler and language-agnostic but pays a process-spawn cost per call.
- Plugin flag registration: Approach B uses env vars (
A2A_TRANSPORT_<NAME>_*) declared in info output. Approach A could support declared flags. Should plugins ever appear in a2a send --help?
- Protocol version contract: How to handle
a2aclient.Transport interface evolution? The info subcommand (B) or handshake (A) should declare supported operations so the host can detect missing capability.
- Streaming error handling (Approach B): If the plugin process exits non-zero mid-stream, how should the host surface the error to the caller?
- Security: Should there be a plugin allowlist or signature verification?
- Conformance test harness: Should the project provide
a2a plugin test ./a2acli-transport-websocket to validate a plugin binary against the contract?
Acceptance Criteria
References
- kubectl plugin mechanism — PATH-based discovery, naming convention
- hashicorp/go-plugin — gRPC-based plugin framework
- Current SLIMRPC pattern:
internal/cli/slim.go + slim_nocgo.go (build-tag gated, 6 global flags)
- Transport registration:
internal/cli/client.go:119-143 (clientFactoryOpts)
Problem Statement
Adding a new transport to the A2A CLI today requires modifying four tightly-coupled locations and recompiling:
internal/flagparse/transports.go:54-67— add alias toparseTransport()switchinternal/cli/root.go:47-70— add transport-specific config fields toglobalConfig+ register new flags (lines 133-138)internal/cli/client.go:119-143— wire the new transport'sa2aclient.FactoryOptionintoclientFactoryOpts()slim.go+slim_nocgo.go) — the transport implementation itself, possibly behind build tagsThe SLIMRPC transport demonstrates this pain: it requires CGO, adds 6 global flags, needs conditional compilation, and anyone wanting to use it must rebuild from source with the right build tags. This model doesn't scale for the growing ecosystem of transport protocols that community and enterprise users need (WebSocket, SLIM variants, proprietary message buses, etc.).
A plugin system would allow third parties to ship transport implementations as standalone binaries without forking or recompiling the CLI.
Goals
--transport <name>works uniformly for built-in transports and pluginsNon-Goals
a2asrv.RequestHandlerextensions) — this proposal covers client transports onlyDiscovery Mechanism
Follow the kubectl plugin convention:
a2acli-transport-<name>(e.g.a2acli-transport-websocket,a2acli-transport-slim-v2)$PATHwhen--transport <name>does not match a built-inResolution order in
parseTransport():a2acli-transport-<name>A new
a2a transport listsubcommand (ora2a plugin list) should scan PATH and report discovered plugin binaries with their declared metadata (version, supported protocol version, description).Approach A: hashicorp/go-plugin (gRPC IPC)
The host CLI launches the plugin binary as a subprocess and communicates over a local gRPC channel using hashicorp/go-plugin.
Plugin contract: a protobuf service matching the
a2aclient.Transportinterface (12 methods) plusHandshakeandDeclareFlagsmethods.The host constructs a
TransportFactorythat:a2acli-transport-<name>via go-pluginTransportadapter proxying each method call over gRPC to the pluginThis adapter is registered as an
a2aclient.FactoryOptionand slots intoclientFactoryOpts()exactly like built-in transports.Pros:
SendStreamingMessage,SubscribeToTask) via multiplexed gRPCCons:
a2aclient.Transportinterfacehashicorp/go-pluginas a new dependency (gRPC is already present for the built-in gRPC transport)Approach B: Subcommand + JSONL (subprocess per call)
Each transport operation maps to a subcommand of the plugin binary. The host spawns the process, passes inputs as CLI arguments and/or stdin JSON, and reads JSONL output from stdout. No long-lived process or bespoke IPC protocol — just a binary that behaves like a well-specified CLI tool.
Subcommand mapping:
SendMessagea2acli-transport-websocket send-message <params-as-json>SendStreamingMessagea2acli-transport-websocket send-message-stream <params-as-json>GetTaska2acli-transport-websocket get-task <params-as-json>CancelTaska2acli-transport-websocket cancel-task <params-as-json>ListTasksa2acli-transport-websocket list-tasks <params-as-json>SubscribeToTaska2acli-transport-websocket subscribe-task <params-as-json>GetExtendedAgentCarda2acli-transport-websocket get-agent-card <params-as-json>a2acli-transport-websocket infoOutput format:
send-message-stream,subscribe-task): one JSONL event per line until stream ends, then process exitsExample:
infosubcommand returns plugin metadata and supported subcommands — used bya2a transport listand for version compatibility checks:Same
FactoryOptionintegration as Approach A — host wraps subcommand invocation behind aTransportadapter.Pros:
Cons:
Integration Points (both approaches)
internal/flagparse/transports.goparseTransport()falls through to plugin lookup for unknown transport namesinternal/cli/root.goglobalConfigfield for plugin pass-through argsinternal/cli/client.goclientFactoryOpts()appends the plugin'sFactoryOptionwhen a plugin transport is activeinternal/cli/plugin.go(new)internal/cli/plugin_transport.go(new)TransportFactoryimplementation proxying to the plugin processFlag passthrough options
--transport <name>, discovers plugin, queries its flag declarations, then re-parses with those flags registered on cobra. Full--helpintegration, but complex.--<transport>-*or after--are passed as raw key-value pairs to the plugin. Simpler, weaker help/validation UX.A2A_TRANSPORT_<NAME>_*. Aligns with the existing.env/clicfgpattern ininternal/clicfg/binder.go. No flag plumbing needed.Open Questions
A2A_TRANSPORT_<NAME>_*) declared ininfooutput. Approach A could support declared flags. Should plugins ever appear ina2a send --help?a2aclient.Transportinterface evolution? Theinfosubcommand (B) or handshake (A) should declare supported operations so the host can detect missing capability.a2a plugin test ./a2acli-transport-websocketto validate a plugin binary against the contract?Acceptance Criteria
a2a send --transport websocket -e ws://agents.example.com/weather "hello"works whena2acli-transport-websocketis on PATHa2a transport listshows discovered plugins with version/description--transportname with no matching plugin produces a clear error with discovery hintsexamples/a2acli-transport-echo/) demonstrating the contractReferences
internal/cli/slim.go+slim_nocgo.go(build-tag gated, 6 global flags)internal/cli/client.go:119-143(clientFactoryOpts)