Skip to content

feat: Plugin system for custom transports via discoverable binaries #20

Description

@Tehsmash

Problem Statement

Adding a new transport to the A2A CLI today requires modifying four tightly-coupled locations and recompiling:

  1. internal/flagparse/transports.go:54-67 — add alias to parseTransport() switch
  2. internal/cli/root.go:47-70 — add transport-specific config fields to globalConfig + register new flags (lines 133-138)
  3. internal/cli/client.go:119-143 — wire the new transport's a2aclient.FactoryOption into clientFactoryOpts()
  4. 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:

  1. Naming: a2acli-transport-<name> (e.g. a2acli-transport-websocket, a2acli-transport-slim-v2)
  2. Discovery: search $PATH when --transport <name> does not match a built-in
  3. Validation: run a handshake/capabilities check before delegating

Resolution order in parseTransport():

  1. Match built-in aliases: rest, jsonrpc, grpc, slimrpc
  2. Search PATH for a2acli-transport-<name>
  3. 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:

  1. Launches a2acli-transport-<name> via go-plugin
  2. Performs a versioned handshake (protocol version negotiation built into go-plugin)
  3. 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

  1. 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.
  2. 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?
  3. 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.
  4. Streaming error handling (Approach B): If the plugin process exits non-zero mid-stream, how should the host surface the error to the caller?
  5. Security: Should there be a plugin allowlist or signature verification?
  6. Conformance test harness: Should the project provide a2a plugin test ./a2acli-transport-websocket to validate a plugin binary against the contract?

Acceptance Criteria

  • a2a send --transport websocket -e ws://agents.example.com/weather "hello" works when a2acli-transport-websocket is on PATH
  • a2a transport list shows discovered plugins with version/description
  • Unknown --transport name with no matching plugin produces a clear error with discovery hints
  • Plugin crash during a streaming operation produces a user-friendly error, not a panic
  • Plugin can declare its own configuration requirements (flags, env vars, or both)
  • Example plugin exists in the repo (e.g. examples/a2acli-transport-echo/) demonstrating the contract
  • Plugin author guide: protocol spec, minimal Go template, and a non-Go example (Python or shell)
  • Existing built-in transports are unaffected — no regressions in rest, jsonrpc, grpc, slimrpc
  • The plugin protocol is versioned so future CLI releases maintain backward compatibility with older plugins

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions