The wire protocol is the contract that lets one process drive a DeepSeek Harness runtime from another. This page is about the protocol itself — the framing, the named message types, and how they relate to sessions and the agent loop. The two ends that speak it are covered in SDK Client and SDK Server; this page cites packages/sdk/protocol/src.
Package and version
@deepseek-ai/dsh-sdk-protocol (packages/sdk/protocol/package.json) is a pure library: no plugin, no Config, no registration. It ships just the transport class and the named wire types, plus the exported JsonRpcResponseError.
| Field | Value |
|---|---|
| name | @deepseek-ai/dsh-sdk-protocol |
| role | Shared wire protocol for the SDK runtime |
| payload | lib/index.js, lib/types/**/*.d.ts |
| peer deps | dsh-invariants, dsh-llm, dsh-session, dsh-subagent, cordis (types only) |
| module | src/index.ts re-exports transport.ts and types.ts |
Design goals
The protocol answers a specific product question: how does an external caller (a Python or TypeScript process) operate a full harness — not just call one model — when the runtime has no terminal UI and no approval surface? Three decisions follow from that.
- Newline-delimited JSON-RPC 2.0 over stdio. The child runtime's
stdoutis reserved for frames; diagnostics belong onstderr. The channel is the process's own pipes, so there is no port, no port scanning, no HTTP. - Durable-event streaming, not prompt results. The server does not answer a prompt with an assistant message. Instead it pushes every session-log event and whole-agent status transition as a notification, and the client assembles a turn from that stream.
- No mid-protocol state machine beyond
initialize.initializeis a handshake that pins cwd/provider/model for the lifetime of the server; everything after is statelesssession/promptrequests.
The transport is JsonRpcLineTransport (packages/sdk/protocol/src/transport.ts). A JsonRpcTransportPeer is the outbound surface (only request/notify) that the server class and the client are both typed against.
Framing rules
Each JSON-RPC 2.0 message is one compact JSON object serialized with JSON.stringify and terminated by \n. Lines are read with a UTF-8 StringDecoder, trimmed, and drained one at a time. Malformed JSON lines are silently ignored; there is no error back-pressure.
outbound (server -> client): {"jsonrpc":"2.0","method":"session.event","params":{...}}\n
{"jsonrpc":"2.0","method":"session.status","params":{...}}\n
inbound (client -> server): {"jsonrpc":"2.0","id":"req_…","method":"session/prompt","params":{...}}\n
{"jsonrpc":"2.0","id":"req_…","result":{"messageId":"…"}}\nFrame classification (from handleLine):
| Frame shape | Meaning |
|---|---|
has id and method | a request — dispatched to the request handler |
id alone | a response — resolves the matching pending request |
method alone | a notification — dispatched to the notification handler |
Requests mint ids as req_<uuid-hex> (randomUUID().replaceAll('-', '')). request(method, params, signal) supports an optional AbortSignal: aborting removes the pending entry (retaining no stale state for a response that may never arrive) and rejects with the signal's reason. flush() waits for prior write callbacks by writing an empty barrier. Error responses become JsonRpcResponseError, which preserves the wire code and optional data.
| Error case | Wire code | Notes |
|---|---|---|
| no request handler installed | -32601 (method not found) | |
| handler rejected | -32603 (internal error) | carries error.message |
close() while requests pending | n/a | pending requests reject with JSON-RPC transport closed |
| malformed JSON line | none | line dropped |
// packages/sdk/protocol/src/transport.ts (abridged)
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
// ...
request(method: string, params: object, signal?: AbortSignal): Promise<unknown> {
const id = `req_${randomUUID().replaceAll('-', '')}`
const message = { jsonrpc: '2.0', id, method, params }
// registers a pending entry, writes the frame, returns a promise
}
notify(method: string, params?: object): void {
this.write(params === undefined
? { jsonrpc: '2.0', method }
: { jsonrpc: '2.0', method, params })
}
}Wire types
packages/sdk/protocol/src/types.ts names every payload. HarnessSdkRequestMap indexes client→server requests; HarnessSdkNotificationMap indexes server→client notifications.
| Direction | Method | Request type → result type |
|---|---|---|
| client→server | initialize | InitializeParams → InitializeResult |
| client→server | session/prompt | SessionPromptParams → SessionPromptResult |
| client→server | shutdown | no params → {} |
| server→client | session.event | SessionEventNotification |
| server→client | session.status | SessionStatusNotification |
| server→client | subagent.started | SubagentStartedNotification |
| server→client | subagent.finished | SubagentFinishedNotification |
initialize
A process-wide handshake. cwd is recorded on every SDK-created session's header; provider and model become the route every SDK-created agent runs on. maxTokens is an optional positive safe-integer output-token cap inherited by SDK-created agents and their in-process descendants; invalid values reject initialization, omission lets the adapter's exact-model default or provider default apply.
// packages/sdk/protocol/src/types.ts (abridged)
export interface InitializeParams {
cwd: string
provider: string
model: string
maxTokens?: number
}
export interface InitializeResult {
serverInfo: { name: string; version: string }
}serverInfo.name is the wire-stable deepseek-harness-sdk-runtime; version is a plain string.
session/prompt
One user turn on one SDK session. sessionId is the SDK-side id; an unknown id lazily creates the agent+session pair on the server. contentBlocks are sent verbatim as the user message. The result is a durable enqueue receipt, not a turn result:
export interface SessionPromptParams {
sessionId: string
contentBlocks: ContentBlock[]
}
export interface SessionPromptResult {
messageId: string // identity of the queued UserMessage
}messageId identifies the queued UserMessage only. It does not identify a later assistant message, a turn ending, or the final answer. Clients therefore combine the open-ended session.event stream with agent-wide session.status to own their own activity interval.
Notifications
Four server→client notifications. The payload types deliberately depend on SessionEvent (dsh-session), ContentBlock (dsh-llm), and SubagentStopReason (dsh-subagent) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract.
| Method | Payload fields | Semantics |
|---|---|---|
session.event | sessionId, event: SessionEvent | one durable session-log event for every session in the runtime, unfiltered |
session.status | sessionId, status: 'idle' | 'running' | whole-agent lifecycle transition |
subagent.started | parentSessionId, childSessionId | an in-runtime child session was created |
subagent.finished | provider, agentId, parentSessionId, childSessionId, status, stopReason, lastAssistantMessage? | an in-process subagent run ended |
// packages/sdk/protocol/src/types.ts (abridged)
export interface SubagentFinishedNotification {
provider: string
agentId: string // equals childSessionId for local runs
parentSessionId: string
childSessionId: string
status: SdkRunStatus // 'ok' | 'error'
stopReason: SubagentStopReason
lastAssistantMessage?: ContentBlock[] // absent when the child produced none
}SdkRunStatus is 'ok' | 'error'. lastAssistantMessage is the child's last non-empty assistant message or, failing that, its accumulated assistant text; it is absent only when the child produced neither. subagent.finished reports only in-process child runs — remote runs are not reported on this wire.
How it maps onto the agent loop
The protocol front-wraps the agent loop's own events. On the server, HarnessSdkJsonRpcServer subscribes to three Cordis lifetime events and re-projects each into a notification:
Cordis event (ctx.on) | Notification emitted |
|---|---|
session/event | session.event |
agent/status | session.status |
session/created (with header.parentSession set) | subagent.started |
subagent/end (only when info.local) | subagent.finished |
There is no push channel for "turn ended with this text." The nearest durable fact is the turn/end session event, which the client reads out of the session.event stream. This is why the SDK README stresses that a prompt-level result is intentionally absent: activity ownership belongs to the observer, not the wire.
Versioning
There is no protocol-version negotiation. The handshake carries only serverInfo.version, which clients do not validate. This is an explicit pre-release stance with no compatibility promise across revisions. Consequence: an SDK client and a runtime must come from the same release train; mixing a client from one revision with a different server revision is out of contract.
Known protocol gaps
| Gap | Effect | Workaround |
|---|---|---|
| No cancel method | a client cannot abandon one turn in isolation | close the runtime process |
| No session-close method | SDK-created agents stay live until process shutdown | — |
| No per-prompt result | messageId is inbox admission only | client owns receipt→idle collection |
| Server→client requests | transport supports them but the server never sends one | Python responder surface reserved for future approval flows |
| No protocol version negotiation | handshake version not validated | keep client and runtime in lockstep |
Worked example
The served protocol is exercised end to end by examples/jsonrpc-agent/cordis.yml, which mounts the sdk-jsonrpc-server plugin inside an otherwise unattended coding-agent composition (bash, fs tools, subagent, todo, JSONL persistence, compaction), and by the dsh-jsonrpc-agent bin whose lifecycle is in packages/examples/jsonrpc-demo/src/runner.ts. Snapshot fixtures under examples/jsonrpc-agent/tests/snapshots/*/notifications.expected.jsonl show the exact session.event/session.status/subagent.* shapes the wire actually carries.
Further reading
- SDK Client — the
DeepSeekHarness/HarnessSession/HarnessClientlayers that speak this protocol. - SDK Server —
@deepseek-ai/dsh-sdk-jsonrpc-serverand how it hosts agents headlessly. - Typert: The Type Generator — the unrelated type/schema generator in the same engineering chapter.
- The distribution composition:
examples/jsonrpc-agent/cordis.yml(which plugins sit behind the server row). - Session vs agent event distinction: Session Management.
- Repo-relative:
packages/sdk/protocol/src/transport.ts,packages/sdk/protocol/src/types.ts.