Skip to content

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.

FieldValue
name@deepseek-ai/dsh-sdk-protocol
roleShared wire protocol for the SDK runtime
payloadlib/index.js, lib/types/**/*.d.ts
peer depsdsh-invariants, dsh-llm, dsh-session, dsh-subagent, cordis (types only)
modulesrc/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.

  1. Newline-delimited JSON-RPC 2.0 over stdio. The child runtime's stdout is reserved for frames; diagnostics belong on stderr. The channel is the process's own pipes, so there is no port, no port scanning, no HTTP.
  2. 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.
  3. No mid-protocol state machine beyond initialize. initialize is a handshake that pins cwd/provider/model for the lifetime of the server; everything after is stateless session/prompt requests.

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.

text
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":"…"}}\n

Frame classification (from handleLine):

Frame shapeMeaning
has id and methoda request — dispatched to the request handler
id alonea response — resolves the matching pending request
method alonea 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 caseWire codeNotes
no request handler installed-32601 (method not found)
handler rejected-32603 (internal error)carries error.message
close() while requests pendingn/apending requests reject with JSON-RPC transport closed
malformed JSON linenoneline dropped
ts
// 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.

DirectionMethodRequest type → result type
client→serverinitializeInitializeParamsInitializeResult
client→serversession/promptSessionPromptParamsSessionPromptResult
client→servershutdownno params → {}
server→clientsession.eventSessionEventNotification
server→clientsession.statusSessionStatusNotification
server→clientsubagent.startedSubagentStartedNotification
server→clientsubagent.finishedSubagentFinishedNotification

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.

ts
// 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:

ts
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.

MethodPayload fieldsSemantics
session.eventsessionId, event: SessionEventone durable session-log event for every session in the runtime, unfiltered
session.statussessionId, status: 'idle' | 'running'whole-agent lifecycle transition
subagent.startedparentSessionId, childSessionIdan in-runtime child session was created
subagent.finishedprovider, agentId, parentSessionId, childSessionId, status, stopReason, lastAssistantMessage?an in-process subagent run ended
ts
// 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/eventsession.event
agent/statussession.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

GapEffectWorkaround
No cancel methoda client cannot abandon one turn in isolationclose the runtime process
No session-close methodSDK-created agents stay live until process shutdown
No per-prompt resultmessageId is inbox admission onlyclient owns receipt→idle collection
Server→client requeststransport supports them but the server never sends onePython responder surface reserved for future approval flows
No protocol version negotiationhandshake version not validatedkeep 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/HarnessClient layers that speak this protocol.
  • SDK Server@deepseek-ai/dsh-sdk-jsonrpc-server and 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.