Skip to main content
PathDocs

Driving Harness from a Program: the SDK

The one-liner: spawn a DeepSeek Harness runtime (an executable you provide plus its cordis.yml) as a subprocess and drive agents turn by turn from your own program over newline-delimited JSON-RPC on stdio — send prompts, subscribe to session events, wait until the whole agent goes idle, then shut down and reap the child gracefully.

DSH is not only an interactive Web UI. packages/sdk/ provides a protocol stack for out-of-process callers: the caller supplies the runtime executable and its cordis.yml, and the SDK launches, drives, and reaps that runtime as an embeddable "engine". The stack does not create, configure, build, or launch developer projects — that is the developer side's concern; the SDK only "boots the runtime and talks to it".

Components and roles

PackageRole
@deepseek-ai/dsh-sdk-protocolDefines the SDK runtime wire protocol: one newline-delimited JSON-RPC transport class plus the named request/result/notification types both ends speak
@deepseek-ai/dsh-sdk-clientTypeScript client, with the high-level DeepSeekHarness and the low-level HarnessClient
@deepseek-ai/dsh-sdk-jsonrpc-serverServer plugin: serves out-of-process SDK clients over newline JSON-RPC on stdio
deepseek-harness-sdkPython edition (deepseek_harness), mirroring the same protocol and layering without importing the TS types

The server: the sdk-jsonrpc-server plugin and the app boundary

The runtime side is handled by a plugin named sdk-jsonrpc-server. It declares inject: ['agents'] (packages/sdk/server/src/index.ts):

export const name = 'sdk-jsonrpc-server'
// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
export const inject = ['agents']

Its core is HarnessSdkJsonRpcServer: on construction it subscribes to session, agent, and subagent lifecycle events and sends notifications; initialize configures the route and, when needed, mounts the DeepSeek adapter fallback; session/prompt gets-or-creates one session per sessionId, queues one user message, and immediately returns the { messageId } enqueue receipt; shutdown disposes all SDK-owned agents, adapter, and subscriptions to quiescence, then the plugin flushes the response, disposes the root context, and exits 0. handleRequest is the method dispatcher (packages/sdk/server/src/server.ts):

async handleRequest(method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
switch (method) {
case 'initialize': return this.initialize(params as InitializeParams)
case 'session/prompt': return this.prompt(params as SessionPromptParams)
case 'shutdown': return this.shutdown()
default: throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`)
}
}

Stdout is the protocol. The plugin README is explicit: stdout carries only JSON-RPC frames; the deployment must not compose a stdout logger; diagnostics belong on stderr (packages/sdk/server/README.md). So the runtime's cordis.yml must omit stdout loggers, and the external jsonrpc-demo app composes "spine + backends + serving plugin".

Transport: JsonRpcLineTransport

The protocol uses a single transport class, JsonRpcLineTransport, to carry JSON-RPC 2.0 frames over caller-owned byte streams, one compact JSON frame per \n-terminated line (packages/sdk/protocol/src/transport.ts):

  • A frame with id plus method is a request; id alone is a response; method alone is a notification; malformed JSON lines are ignored.
  • start() attaches stream listeners; close() detaches them and rejects pending requests without destroying the streams.
  • A missing request handler answers -32601; a handler rejection answers -32603 with the message. An error response rejects the pending request() with JsonRpcResponseError, which preserves the wire code and optional data.
private write(message: Record<string, unknown>): void {
this.output.write(`${JSON.stringify(message)}\n`)
}

Wire methods

The protocol names every payload (HarnessSdkRequestMap / HarnessSdkNotificationMap in packages/sdk/protocol/src/types.ts):

DirectionMethodPayloadNotes
client→serverinitializeInitializeParamsInitializeResultProcess-wide handshake: cwd + provider/model route + optional positive maxTokens
client→serversession/promptSessionPromptParamsSessionPromptResultQueues one user message and immediately returns the durable { messageId } receipt
client→servershutdownno params → {}Disposes SDK-owned agents/adapter/subscriptions to quiescence, then exits
server→clientsession.eventSessionEventNotificationThe full session-log event for every session in the runtime (unfiltered), streamed as recorded
server→clientsession.statusSessionStatusNotificationWhole-agent running / idle transition
server→clientsubagent.startedSubagentStartedNotificationAn in-runtime child session was created
server→clientsubagent.finishedSubagentFinishedNotificationAn in-process subagent run ended (local runs only; remote runs are not reported)

Key semantics, from the protocol README / type JSDoc:

  • SessionPromptResult.messageId identifies the queued UserMessage only; it does not denote a later assistant message, a turn ending, or a prompt result.
  • Clients combine the open-ended session.event stream with whole-agent session.status to own their activity interval.
  • SubagentFinishedNotification.lastAssistantMessage carries the child's last non-empty assistant message; the field is absent when it produced neither.
  • InitializeParams.maxTokens is an optional positive safe integer capping each conversation-model output for SDK-created agents (and in-process descendants).
  • serverInfo.name stays the wire-stable deepseek-harness-sdk-runtime (current version 0.0.1, unvalidated).

The protocol README also states honest limits: no protocol-version negotiation (serverInfo.version unvalidated), no cancel or session-close methods (abandon a turn by closing the runtime process), and server→client requests are a "dead capability" (the transport supports them but the server never sends one — reserved for future approval flows).

Client: two layers

High-level DeepSeekHarness (owns the run lifecycle)

import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'

await using harness = new DeepSeekHarness({
launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
maxTokens: 49_152,
})
const result = await harness.run('say hi')
console.log(result.finalResponse)

(packages/sdk/client/README.md and src/api.ts) Key points:

  • The subprocess starts lazily on first use and stays owned by this instance across run() calls; close() (or await using) is required so the child is always reaped.
  • start() memoizes the initialize handshake; a failed handshake reaps the runtime and swaps in a fresh client so a later call retries with a new subprocess (until close() is terminal).
  • run(input, { sessionId?, onNotification? }) owns one activity interval: queue the prompt → wait until its MessageId appears in the durable agent/inbox/spliced receipt → collect through the next whole-agent idle. Returns RunResult { sessionId, finalResponse, events, notifications }.
  • finalResponse is the last committed root-session assistant text in the interval, not a response causally assigned to the prompt — steering, injected context, and other queued work may contribute before idle. events contains only root-session events, while notifications also includes descendants discovered from subagent.started, all in wire order.
  • session(id?) opens a named or fresh session handle.

HarnessSession.run in src/api.ts is that "receipt → idle" collection loop:

const messageId = await client.prompt(this.id, contentBlocks)
let received = false
while (true) {
const notification = await subscription.next()
if (!received) {
if (notification.method !== 'session.event'
|| notification.params.sessionId !== this.id
|| !isInboxReceipt(notification.params.event, messageId)) continue
received = true
}
collect(notification)
if (notification.method === 'session.status'
&& notification.params.sessionId === this.id
&& notification.params.status === 'idle') break
}

Low-level HarnessClient (fine-grained control)

The protocol client (src/client.ts): explicit start() / initialize() / prompt() / request() / close(), plus notification subscriptions.

  • prompt(sessionId, contentBlocks) returns the queued message id as soon as the runtime accepts it; it never waits for agent activity.
  • subscribe(filter?) returns a NotificationSubscription (awaitable next(), non-blocking tryNext(), async iteration); subscribeSessionTree(id) scopes to one session and the descendants discovered from subagent.started lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK.
  • Typed errors (src/client.ts): JsonRpcResponseError (wire error response, code/data preserved), RequestTimeoutError (a bound elapsed), SdkProtocolError (a response outside the documented protocol), TransportClosedError (the runtime is gone — message carries the exit code and a bounded stderr tail).
  • close() requests protocol shutdown (bounded by shutdownTimeoutMs, default 1000ms), then walks the stdin-EOF → SIGTERM → SIGKILL ladder (disposeEofGraceMs default 6000, disposeGraceMs default 3000) until the process has actually exited. The ladder runs outside any harness context, so it does not go through the dsh-subprocess service — the seam's documented exception. It is idempotent, and a closed client refuses reuse.
  • HarnessClientOptions.env replaces the child environment entirely when given (undefined inherits the parent's); callers own credential policy (scrubbedParentEnv from dsh-subprocess is the shared base for isolation-minded launches).

Both clients are pure libraries: they register nothing on a Cordis context. The runtime process they spawn is a complete harness whose composition its own cordis.yml decides.

The Python SDK

deepseek-harness-sdk (module deepseek_harness) is the design twin of the TypeScript client, sharing the same runtime peer, protocol, and layering (python/README.md, python/sdk/README.md, python/sdk/src/deepseek_harness/).

python -m pip install deepseek-harness-sdk
from deepseek_harness import DeepSeekHarness

with DeepSeekHarness(
provider="deepseek-official",
model="deepseek-v4-flash",
max_tokens=49_152,
) as harness:
result = harness.run("Make the requested code change.")

Key differences on the Python side:

  • Bundled runtime distribution: installing deepseek-harness-sdk installs the same-version deepseek-harness-runtime-bin platform wheel, so the default entry point needs no executable argument — it launches the bundled single-file dsh-jsonrpc-agent executable. On the TS side the launch spec is fully explicit (command/args); bundled-runtime resolution stays Python-side for now.
  • A default composition is injected via DSH_CORDIS_CONFIG (stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence with an explicitly composed semantic checkpoint policy, local bash); to run your own plugin composition, keep the dsh-sdk-jsonrpc-server entry and pass the Cordis config path.

The Python high-level DeepSeekHarness is likewise reusable (context manager or explicit close()).

@dataclass(slots=True)
class RunResult:
session_id: str
final_response: str
finish_reason: str | None
events: list[JsonObject]
notifications: list[Notification]
session_root: str | None = None

Session.run() owns an activity interval from the prompt's durable inbox receipt through the next whole-agent idle and returns the RunResult above. finish_reason is the kind of the last root-session turn/end in the interval (such as completed / max-tokens / error; None when no turn ended); final_response is the last committed root-session assistant text in the interval — both describe the interval, not an output or ending causally assigned to the prompt. run() also accepts and forwards on_notification, and the low-level session_prompt() (mapping to session/prompt) returns only the queued MessageId — callers that bypass Session.run() own any later activity boundary themselves.

Why this satisfies "drive DSH as an embedded runtime"

  1. The seam is a pure process boundary — the runtime is a subprocess and stdio is a factory protocol; no UI, network port, or global install is required, so it embeds in CI, batch jobs, and orchestrators.
  2. Stdout purity — protocol frames own stdout, diagnostics go to stderr, and no logging can pollute programmatic parsing.
  3. Well-defined activity intervalsrun() (or your own combination of session.event + session.status) abstracts "send a prompt → wait until the whole agent is idle" into a single awaitable call, returning finalResponse plus the full event/notification stream.
  4. Complete lifecycleawait using / the context manager guarantees the child is reaped; close() walks the protocol shutdown + EOF/SIGTERM/SIGKILL ladder to an idempotent terminal state.
  5. Symmetric, two languages — TS and Python speak the same wire; pick either. The cross-process subagent backend (dsh-sdk subagent provider) is exactly what runs each subagent as a full peer harness through this TS client.

Verify / try it

The repo ships a jsonrpc-demo app (packages/examples/jsonrpc-demo/README.md): a bin-only app that boots an external cordis.yml, whose jsonrpc entry serves SDK clients over newline-delimited stdio.

# 1) Run the demo inside the DSH source repo: the composed server
pnpm --dir /path/to/deepseek-harness run demo:jsonrpc

Config discovery order: $DSH_CORDIS_CONFIG wins, then the positional argv[2]. If neither names an existing file, the bin prints a one-line usage to stderr and exits 1; there is no working-directory or built-in fallback. A config without dsh-sdk-jsonrpc-server is valid and serves nothing.

# 2) Drive it from a session (swap in a real model)
node -e "import('@deepseek-ai/dsh-sdk-client').then(async ({ DeepSeekHarness }) => {
await using const h = new DeepSeekHarness({
launch: { command: 'dsh-jsonrpc-agent', args: ['cordis.yml'] },
provider: 'deepseek-official', model: 'deepseek-v4-flash',
});
const r = await h.run('say hi');
console.log(r.finalResponse);
})"

Or hand-write a newline JSON-RPC frame into the runtime's stdin and watch stdout carry only protocol frames (diagnostics live on stderr):

# 3) Raw frames: initialize handshake, then diagnose on stderr while stdout shows only JSON
printf '%s\n' \
'{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"cwd":"'$PWD'","provider":"deepseek-official","model":"deepseek-v4-flash"}}' \
'{"jsonrpc":"2.0","id":"2","method":"shutdown"}' \
| dsh-jsonrpc-agent cordis.yml 2>/dev/null

To observe an activity interval yourself, subscribe to the session.event / session.status notification stream (see the wire-methods table above), or use the high-level run(input, { onNotification }) to print each notification.

Next steps

  • To see this API used as a subagent backend inside the harness, see Subagents and Parallelism.
  • The full property and limitation references live in the source: packages/sdk/{protocol,client,server}/README{.zh}.md and python/sdk/README{.zh}.md.