Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Transport Architecture

For the broader user-facing API, see the Core Library Design and the agent-client-protocol rustdoc.

This chapter explains how the connection layer separates protocol semantics from transport mechanisms, enabling flexible deployment patterns including in-process message passing.

Overview

The SDK’s connection core provides the JSON-RPC abstraction used by ACP components. It supports pluggable transports that work with different I/O mechanisms while maintaining consistent protocol semantics.

Design Principles

Separation of Concerns

The architecture separates two distinct responsibilities:

  1. Protocol Layer: JSON-RPC semantics

    • Request ID assignment
    • Request/response correlation
    • Method dispatch to handlers
    • Error handling
  2. Transport and framing layer: Message movement and JSON-RPC envelope validation

    • Reading/writing from I/O sources
    • Serialization/deserialization
    • Preserving single-value and batch boundaries
    • Connection management

This separation enables:

  • In-process efficiency: Components in the same process can skip serialization
  • Transport flexibility: Easy to add new transport types (WebSockets, named pipes, etc.)
  • Testability: Mock transports for unit testing
  • Clarity: Clear boundaries between protocol and I/O concerns

The TransportFrame Boundary

The public, transport-neutral boundary is TransportFrame. A frame contains one RawJsonRpcMessage, one structurally non-empty TransportBatch, or a malformed wire value that a relay must preserve. RawJsonRpcMessage is backed by the JSON-RPC envelope types from agent-client-protocol-schema:

#![allow(unused)]
fn main() {
enum RawJsonRpcMessage {
    Request(Request<RawJsonRpcParams>),
    Notification(Notification<RawJsonRpcParams>),
    Response(RawJsonRpcResponse),
}
}

At that boundary:

  • Above: Protocol layer works with application types (OutgoingMessage, UntypedMessage)
  • Below: Transport actors parse and serialize JSON-RPC frames
  • Boundary: TransportFrame carries one raw message, a structurally non-empty batch, or a malformed wire value retained for a relay
  • In-process API: Channel::rx and Channel::tx carry TransportFrame directly, so adapters cannot accidentally flatten a batch
  • Failures: I/O and connection failures are returned by the future driving a transport; they are not sent as channel entries

TransportFrame::parse_json returns one frame for every input string, including malformed response-shaped input. Standalone malformed input retains its exact text. Batch entries retain their parsed JSON values, source order, and batch boundary, although serializing a relayed batch may normalize whitespace. The protocol actor, not the parser, decides whether malformed input requires a response.

Actor Architecture

Protocol Actors

These actors live in the protocol connection core and understand JSON-RPC semantics:

Outgoing Protocol Actor

Input:  mpsc::UnboundedReceiver<OutgoingMessage>
Output: mpsc::UnboundedSender<TransportFrame>

Responsibilities:

  • Assign unique IDs to outgoing requests
  • Register pending replies before sending requests
  • Convert application-level OutgoingMessage to protocol-level RawJsonRpcMessage

Incoming Protocol Actor

Input:  mpsc::UnboundedReceiver<TransportFrame>
Output: Routes to pending request awaiters or registered handlers

Responsibilities:

  • Route responses to pending request awaiters (matched by ID)
  • Route requests/notifications to registered handlers
  • Convert schema request/notification envelopes to UntypedMessage for handlers
  • Retain batch response slots while entries are dispatched
  • Emit one response array only after every response-bearing entry has completed and all entries in the batch have been dispatched
  • Emit nothing for a notification-only batch; answer an empty batch with one standalone Invalid Request response

Pending Reply Registry

The shared pending-reply registry manages request/response correlation:

  • Maintains map from request ID to response channel
  • When response arrives, delivers to waiting request

Task Actor

Runs user-spawned concurrent tasks via cx.spawn().

Transport Actors

These actors are driven by physical transport components. They understand JSON-RPC framing and envelope validity, but they do not dispatch ACP methods or correlate responses with pending requests:

Transport Outgoing Actor

Input:  mpsc::UnboundedReceiver<TransportFrame>
Output: Writes to I/O (byte stream, channel, socket, etc.)

For byte streams:

  • Serialize a single RawJsonRpcMessage or one non-empty batch to JSON
  • Write newline-delimited JSON to stream

For in-process channels:

  • Directly forward TransportFrame to the channel

Transport Incoming Actor

Input:  Reads from I/O (byte stream, channel, socket, etc.)
Output: mpsc::UnboundedSender<TransportFrame>

For byte streams:

  • Read newline-delimited JSON from stream
  • Parse a single message or a non-empty batch array
  • Retain the batch boundary while dispatching each RawJsonRpcMessage entry to the incoming protocol actor
  • Retain malformed entries so relays can forward the complete frame
  • Leave call/response-shape classification and Error Response decisions to the incoming protocol actor

For in-process channels:

  • Directly forward TransportFrame from the channel

The public Channel boundary preserves complete frames. The SDK continues to initiate requests and notifications as individual JSON-RPC messages; response arrays are correlated replies to batch calls received from the peer. Relays and instrumentation must forward frames intact so they do not change those wire semantics.

JSON-RPC Batch Behavior

Batch support is shared by the stable v1 and draft v2 APIs because it belongs to the JSON-RPC transport layer:

  • Lines, ByteStreams, Stdio, and the HTTP/WebSocket adapters accept incoming JSON-RPC arrays.
  • Entries are validated and dispatched independently and in source order. An invalid call-shaped entry receives its own Invalid Request error without preventing valid siblings from running.
  • Responses for response-bearing entries are collected and written as one response array after dispatch completes. A notification-only batch receives no response.
  • If a handler drops a batched request’s Responder, the completed dispatch supplies an Internal Error for that slot so completed siblings are not stranded. Returning a handler error supplies that error instead. Dropping an individual request’s responder continues to send no automatic response.
  • An empty input array receives one standalone Invalid Request response; the SDK never writes an empty response array.
  • Response entries are routed by request ID. The framing layer retains malformed values, but the protocol actor ignores values that are response-shaped and not call-shaped because a JSON-RPC response must not itself receive a response; ambiguous call-shaped values still receive Invalid Request.
  • The SDK does not originate batches of requests or notifications. Individual calls continue to receive individual responses; a response array is emitted only for an incoming call batch.

Relays, wrappers, and tracing bridges must forward the complete TransportFrame. Flattening a batch changes observable JSON-RPC semantics even when every individual message remains valid.

Lifecycle-sensitive calls should normally be sent individually. As a compatibility measure, AgentProtocolRouter can select a v1 or v2 agent when the first call-shaped entry is initialize, while preserving the original frame for the selected implementation. Response-only frames received before initialization are ignored. ProxyProtocolRouter provides the analogous boundary after conductor selection: it requires an exact v1 or v2 _proxy/initialize match and preserves the complete initial frame without cross-version conversion. ClientProtocolConnector starts each attempted client implementation with an individual initialize request.

Message Flow

Outgoing Message Flow

User Handler
    |
    | OutgoingMessage (request/notification/response)
    v
Outgoing Protocol Actor
    | - Assign ID (for requests)
    | - Subscribe to replies
    | - Convert to RawJsonRpcMessage
    v
    | TransportFrame (single message or batch response)
    |
Transport Outgoing Actor
    | - Serialize (byte streams)
    | - Or forward directly (channels)
    v
I/O Destination

Incoming Message Flow

I/O Source
    |
Transport Incoming Actor
    | - Parse (byte streams)
    | - Or forward directly (channels)
    v
    | TransportFrame (single message or incoming batch)
    |
Incoming Protocol Actor
    | - Route responses → pending request awaiters
    | - Route requests → registered handlers
    v
Handler or request awaiter

Message Ordering in the Conductor

The conductor’s central routing loop serializes forwarding decisions for incoming requests and notifications. Responses stay paired with the request contexts managed by the protocol layer and may take a direct response path. The conductor therefore does not promise a global total order across unrelated concurrent requests, but every underlying transport sink preserves the order in which complete frames are accepted. See Conductor Routing and Ordering.

Component Boundary

ConnectTo is the common component and transport abstraction. connect_to joins a component to its counterpart and drives the connection until completion. into_channel_and_future exposes the canonical low-level boundary as a Channel plus an explicit connection driver:

fn into_channel_and_future(self) -> (Channel, Option<ConnectionDriver>);

The channel carries only TransportFrame wire events. The optional driver distinguishes owned work from a passive endpoint:

Returned workLifetime rule
Some(ConnectionDriver::new(future))Poll the owned work alongside traffic; successful completion ends it after accepted output is drained.
NoneNo work is owned here; each channel half determines its own lifetime.

A ConnectionDriver always contains a real future; the optional return value cannot itself be awaited. There is no ready-successful passive driver and no finish hook in the None case. This makes the ownership decision explicit rather than requiring callers to recognize a special future.

A raw Channel returns None. Its bridge preserves both directions independently: one sender closing must not prevent a final response in the reverse direction. Owned completion lets a bridge stop accepting new output, drain frames already accepted, and finish without waiting for unrelated remote input to close. Outbound forwarding must remain polled while owned work is running; otherwise a component waiting for a response to its own request could deadlock.

Buffered adapters are responsible for flushing their accepted output before reporting successful completion. The built-in line and byte-stream adapters keep the read half moving during write drain and propagate incoming errors; their explicit finish handling does not require remote read EOF. ConnectionDriver::with_finish(future, finish) lets custom adapters declare the same cooperative contract. Its one-shot, nonblocking hook requests finish after protocol output handoff; the still-polled future performs the drain and reports completion or I/O errors. request_finish() exposes this request to low-level callers and is idempotent: a supported request remains supported after it has been issued. The callback still runs at most once. Neither requesting finish nor dropping the hook proves a successful flush, and no implicit timeout is imposed.

ConnectionDriver::new(future) remains appropriate for opaque cancellable work. A finite foreground does not wait indefinitely for such work after handing off protocol output. Merely wrapping an arbitrary future cannot make an opaque custom adapter drain safely.

Most components implement only connect_to; default normalization supplies Some(driver) containing the owned work. Direct transports override into_channel_and_future to avoid an intermediate copy. Wrappers that expose an existing endpoint should forward its driver unchanged so absence and cooperative completion handling are not lost. Wrappers that decorate execution use map_future to transform the owned future while preserving its finish capability and requested state. Direct custom transport entry points must also request and await cooperative finish after a finite peer completes; a bare join does not provide that step.

See Migrating Connection Drivers for custom override changes. This lifecycle distinction does not change the existing raw channel types or introduce frame-size, queue, or task limits.

Transport Implementations

Byte Stream Transport

ByteStreams<Outgoing, Incoming> works with futures::io::AsyncWrite and AsyncRead. It adapts them to Lines, parses each incoming JSON value into one frame, and serializes each outgoing frame to one newline-delimited JSON value. Runtime adapters with different I/O traits can instead bridge asynchronous lines into Lines.

The native Stdio and AcpAgent transports build on ByteStreams and require the independent stdio and process features, respectively. No features are enabled by default. Their implementations depend on process spawning and blocking-thread facilities, so their dependencies and exports remain excluded on WebAssembly even when enabled. The runtime-neutral protocol engine and transport abstractions compile for wasm32-wasip1 and wasm32-wasip2 without additional features. JavaScript-hosted wasm32-unknown-unknown builds require the opt-in wasm_js feature, which selects Web Crypto through wasm-bindgen as the UUID randomness backend. Since the target does not imply a JavaScript host, other OS-less WebAssembly hosts must arrange a compatible UUID randomness backend instead. This crate does not provide a WebAssembly executor or host I/O adapter.

The rmcp integration library also compiles for WASI with its default features or MCP-over-ACP features. It uses Tokio’s async I/O utilities and single-thread-capable runtime without requiring native stdio or a multithreaded executor. Embedders still provide their runtime and host transport. See Cargo Features for configuration and migration examples.

Use cases:

  • Stdio connections to subprocess agents
  • TCP socket connections
  • Unix domain sockets
  • Any stream-based I/O

In-Process Channel

For components in the same process, Channel::duplex() creates paired endpoints and skips serialization entirely. Relays forward each received TransportFrame without unpacking it; this preserves batch boundaries and the original representation of malformed wire input.

Benefits:

  • Zero serialization overhead: Messages passed by value
  • Same-process efficiency: Ideal for conductor with in-process proxies
  • Explicit wire state: No serialize/parse round trip is required, while a malformed value received from a physical transport remains an explicit frame

Typed Component Interface

The community yosh:acp@8.0.1 package defines an independently versioned typed WIT projection of ACP for the WASI 0.3 async component model. In that design, the host processes the ACP JSON-RPC connection outside the component and maps it to typed calls across the component boundary. The package provides interface definitions, not a transport or integration supplied by this SDK.

Use Cases

1. Standard Agent (Stdio)

Use native Stdio for the current process or AcpAgent for a child process. Both use the same frame-aware line transport underneath.

2. In-Process Proxy Chain

Connect builders, proxies, and conductor components directly. Their default ConnectTo adapter uses Channel, so complete frames cross each wrapper with no serialization.

3. Network-Based Components

Split the socket and pass compatible read/write halves to ByteStreams::new.

4. WebAssembly Embedding

Embedders supply and drive their own runtime and host transport:

  • Exchange TransportFrame values through an in-component Channel. A caller using ConnectTo::into_channel_and_future polls a present owned driver alongside traffic. When no driver is returned, preserve the channel halves’ independent lifetimes; absence is not EOF.
  • Exchange newline-delimited JSON through Lines, using a futures::Sink<String> and futures::Stream<Item = std::io::Result<String>>.
  • Use ByteStreams with futures::io::AsyncRead and AsyncWrite.

5. Testing with Mock Transport

Use Channel::duplex() to inject and inspect TransportFrame values without real I/O.

Benefits

Performance

  • In-process optimization: Skip serialization when components are co-located
  • Zero-copy potential: Direct message passing for channels
  • Flexible trade-offs: Choose appropriate transport for deployment

Flexibility

  • Transport-agnostic handlers: Write handler logic once, use anywhere
  • Easy experimentation: Try different transports without code changes
  • Future-proof: Add new transports (WebSockets, gRPC, etc.) without refactoring

Testing

  • Mock transports: Unit test handlers without I/O
  • Deterministic tests: Control message timing precisely
  • Isolated testing: Test protocol logic separate from I/O

Clarity

  • Clear boundaries: Protocol semantics vs transport mechanics
  • Focused implementations: Each layer has single responsibility
  • Maintainability: Changes to transport don’t affect protocol logic