JSON-RPC Transport Focus Deep-Dive
Every SDK talks to the CLI through the same wire shape: LSP-style Content-Length headers, JSON-RPC 2.0 messages, bidirectional request/response/notification flow. This page explains the framing, the bidirectional dispatch, and why stdio beats the obvious alternatives.
📚 What is this & why does it exist?
A transport that lets four SDKs in four languages speak to one CLI without HTTP, without IPC sockets, without shared memory. Each SDK spawns the CLI as a subprocess, writes Content-Length: N\r\n\r\n{json...} to its stdin, and reads the same shape from its stdout. Bidirectional means the CLI can also initiate calls to the SDK (tool invocations, permission requests), so both sides run request dispatchers.
💡 Real-world analogy
Imagine two submarines communicating through a pneumatic tube. Each capsule has a label saying "contents: 487 bytes" (Content-Length) before the actual message. Either submarine can send, either can respond, either can ask a question. No harbour, no shared air, no radio licences — just a physical tube between them that they both understand the same way.
🎯 The Problem
You have a single CLI binary that orchestrates an LLM agent. You want to embed it into a Node.js app, a Python script, a Go service, or a .NET API — without forcing any of them to fork the CLI source. The transport must: (1) handle request/response correlation with IDs, (2) allow either side to initiate calls (tool invocations flow CLI→SDK), (3) stream notifications (events with no reply), (4) work identically on Linux, macOS, Windows, (5) require zero runtime configuration from the user. It must also be simple enough to implement in Go with 400 lines and in Python with 300.
🔧 The Approach
Use JSON-RPC 2.0 as the payload format (well-known, self-describing, language-neutral), Content-Length framing borrowed from LSP (unambiguous message boundaries, works on any byte stream), and stdio as the transport (subprocess spawn is a solved problem; no port binding; no firewall). Each side runs two halves: a reader that pulls one framed message at a time and dispatches by kind, and a writer that serialises outgoing messages.
Message kinds (JSON-RPC 2.0):
- Request —
{id, method, params}; requires a response. - Response —
{id, result}or{id, error}; correlated by id. - Notification —
{method, params}(noid); fire-and-forget.
🔄 Why Not Alternatives?
| Approach | Pros | Cons | Why Not Chosen |
|---|---|---|---|
| HTTP + REST | Familiar, trivially debuggable | Request/response only; needs port; firewall complexity; server lifecycle | Can't do bidirectional cleanly; tool calls CLI→SDK would need long-polling |
| gRPC | Efficient, bidirectional streaming | Requires code-gen toolchain per language, protobuf schema, HTTP/2 stack | Dep-heavy; static-binary Go SDK doesn't want linked protobuf |
| Raw sockets / Unix domain | Fast, bidirectional | Windows semantics different (named pipes); socket lifecycle management | Stdio is simpler & identical across OSes |
| MessagePack / CBOR over stdio | Smaller payloads | Not human-readable; additional dep in each language | JSON is good-enough; debuggability > bytes |
| Line-delimited JSON | Simpler framing | Breaks if payload contains unescaped newlines; needs sanitisation | Content-Length is unambiguous and well-understood (LSP) |
Overview Diagram
flowchart LR
subgraph sdk["SDK (any language)"]
WR[writer goroutine/task]
RD[reader goroutine/task]
PEND[pending requests map
id -> promise]
DISP[dispatcher
method -> handler]
end
subgraph cli["CLI subprocess"]
CW[writer]
CR[reader]
CP[pending map]
CD[dispatcher]
end
STDIN[stdin pipe]
STDOUT[stdout pipe]
WR -->|Content-Length frame| STDIN
STDIN --> CR
CR --> CD
CR --> CP
CW -->|Content-Length frame| STDOUT
STDOUT --> RD
RD --> DISP
RD --> PEND
Step-by-Step Frame Handling
Read Content-Length header
// go/internal/jsonrpc2/frame.go
func ReadMessage(r *bufio.Reader) ([]byte, error) {
var n int64
for {
line, err := r.ReadString('\n')
if err != nil { return nil, err }
if line == "\r\n" { break } // end of headers
if strings.HasPrefix(line, "Content-Length:") {
fmt.Sscanf(line, "Content-Length: %d", &n)
}
}
body := make([]byte, n)
_, err := io.ReadFull(r, body)
return body, err
}
Why read line-by-line first? The header block is unbounded in length but bounded in structure (\r\n\r\n terminator). Reading the exact body size in one shot prevents partial reads.
Unmarshal and route by kind
var raw struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Result json.RawMessage `json:"result"`
Error *Error `json:"error"`
}
json.Unmarshal(body, &raw)
switch {
case raw.Method != "" && len(raw.ID) > 0: // request
go handleRequest(raw)
case raw.Method != "": // notification
go handleNotification(raw)
default: // response
completePending(raw.ID, raw.Result, raw.Error)
}
Why json.RawMessage for ID? JSON-RPC allows the id to be a number OR a string OR null — a RawMessage avoids committing to a type too early.
Correlate responses
pending := map[string]chan Response{}
// When sending:
ch := make(chan Response, 1)
pending[id] = ch
writeFrame(req)
// When the reader sees a response with matching id:
pending[id] <- resp
delete(pending, id)
Why a buffered channel? The reader goroutine delivers the response even if the caller has not yet selected on the channel. Unbuffered would deadlock the reader.
Writer serialisation
func WriteMessage(w io.Writer, body []byte) error {
hdr := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
if _, err := w.Write([]byte(hdr)); err != nil { return err }
_, err := w.Write(body)
return err
}
Crucially, the write is serialised by a mutex so concurrent senders never interleave frames.
Bidirectional Flow
sequenceDiagram
participant USR as User code
participant SDK as SDK Client
participant CLI as CLI Server
USR->>SDK: session.send(prompt)
SDK->>CLI: session.send (req, id=7)
CLI-->>SDK: assistantMessage (notification)
CLI->>SDK: tool.invoke shell (req, id=42)
Note over SDK: dispatches to user tool handler
SDK-->>CLI: tool result (resp, id=42)
CLI-->>SDK: toolCallCompleted (notification)
CLI-->>SDK: sessionIdle (notification)
CLI-->>SDK: response to session.send (id=7)
SDK-->>USR: messageId returned
The sequence above shows that a single user call may trigger many notifications and at least one server→client request before its own response arrives.
Error Handling
| Scenario | Behaviour | Code |
|---|---|---|
| Truncated frame (EOF mid-body) | Reader returns error; all pending promises rejected | io.ErrUnexpectedEOF |
| Invalid JSON body | Drop frame, log; don't crash | Resilience for CLI bugs |
| Unknown method in request | Respond with JSON-RPC error code -32601 | Method not found |
| Handler throws | Catch, respond with error code -32000 | Server error |
| Timeout on pending | Reject promise; entry eventually GC'd | Caller-driven |
⚖️ Complexity & Tradeoffs
Time. Frame read: O(bytes). Dispatch: O(1) map lookup. Response correlation: O(1). Space. Pending-requests map bounded by in-flight count (typically <10). Throughput. Limited by JSON serialisation cost and pipe buffer size (usually 64KB). Latency. 1-2ms round-trip locally; LSP-style stdio is faster than localhost TCP because no TCP handshake or Nagle.
Tradeoff: JSON is larger than binary. For a chat SDK where the dominant payload is LLM-generated text, JSON overhead is a rounding error. For a high-throughput RPC system, MessagePack would be worth it.
stdio vs TCP
All four SDKs support both. TCP is used when CLIUrl is set — typically for debugging (attach the SDK to a separately launched CLI) or for containerised setups where the CLI runs in a sidecar. The framing is identical; only the byte transport changes. stdio is default because:
- No port allocation or clash with user's services.
- Automatic lifetime binding: when the SDK process exits, the CLI's stdin EOFs and the CLI exits too. No orphan processes.
- No auth model needed — the subprocess inherits the parent's credentials; third parties can't connect.
Directory Map
Files touching the transport
nodejs/
├── src/client.ts ← uses vscode-jsonrpc (external library)
└── node_modules/vscode-jsonrpc/ ← Content-Length framing implementation
python/copilot/
└── _jsonrpc.py ← hand-rolled framing + threaded reader
go/internal/jsonrpc2/
├── jsonrpc2.go ← Client, handlers, pending map
└── frame.go ← ReadMessage / WriteMessage
dotnet/src/ ← STJ-based framing inside Client.cs
Related Topics
- Protocol Negotiation — how the handshake is itself a transport-layer exchange.
- Codegen Pipeline — how RPC method wrappers are generated on top of this transport.