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
| Package | Role |
|---|---|
@deepseek-ai/dsh-sdk-protocol | Defines 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-client | TypeScript client, with the high-level DeepSeekHarness and the low-level HarnessClient |
@deepseek-ai/dsh-sdk-jsonrpc-server | Server plugin: serves out-of-process SDK clients over newline JSON-RPC on stdio |
deepseek-harness-sdk | Python 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
idplusmethodis a request;idalone is a response;methodalone 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-32603with the message. An error response rejects the pendingrequest()withJsonRpcResponseError, which preserves the wirecodeand optionaldata.
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):
| Direction | Method | Payload | Notes |
|---|---|---|---|
| client→server | initialize | InitializeParams → InitializeResult | Process-wide handshake: cwd + provider/model route + optional positive maxTokens |
| client→server | session/prompt | SessionPromptParams → SessionPromptResult | Queues one user message and immediately returns the durable { messageId } receipt |
| client→server | shutdown | no params → {} | Disposes SDK-owned agents/adapter/subscriptions to quiescence, then exits |
| server→client | session.event | SessionEventNotification | The full session-log event for every session in the runtime (unfiltered), streamed as recorded |
| server→client | session.status | SessionStatusNotification | Whole-agent running / idle transition |
| server→client | subagent.started | SubagentStartedNotification | An in-runtime child session was created |
| server→client | subagent.finished | SubagentFinishedNotification | An in-process subagent run ended (local runs only; remote runs are not reported) |
Key semantics, from the protocol README / type JSDoc:
SessionPromptResult.messageIdidentifies the queuedUserMessageonly; it does not denote a later assistant message, a turn ending, or a prompt result.- Clients combine the open-ended
session.eventstream with whole-agentsession.statusto own their activity interval. SubagentFinishedNotification.lastAssistantMessagecarries the child's last non-empty assistant message; the field is absent when it produced neither.InitializeParams.maxTokensis an optional positive safe integer capping each conversation-model output for SDK-created agents (and in-process descendants).serverInfo.namestays the wire-stabledeepseek-harness-sdk-runtime(current version0.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()(orawait using) is required so the child is always reaped. start()memoizes theinitializehandshake; a failed handshake reaps the runtime and swaps in a fresh client so a later call retries with a new subprocess (untilclose()is terminal).run(input, { sessionId?, onNotification? })owns one activity interval: queue the prompt → wait until itsMessageIdappears in the durableagent/inbox/splicedreceipt → collect through the next whole-agent idle. ReturnsRunResult { sessionId, finalResponse, events, notifications }.finalResponseis 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.eventscontains only root-session events, whilenotificationsalso includes descendants discovered fromsubagent.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 aNotificationSubscription(awaitablenext(), non-blockingtryNext(), async iteration);subscribeSessionTree(id)scopes to one session and the descendants discovered fromsubagent.startedlineage 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 protocolshutdown(bounded byshutdownTimeoutMs, default 1000ms), then walks the stdin-EOF → SIGTERM → SIGKILL ladder (disposeEofGraceMsdefault 6000,disposeGraceMsdefault 3000) until the process has actually exited. The ladder runs outside any harness context, so it does not go through thedsh-subprocessservice — the seam's documented exception. It is idempotent, and a closed client refuses reuse.HarnessClientOptions.envreplaces the child environment entirely when given (undefinedinherits the parent's); callers own credential policy (scrubbedParentEnvfromdsh-subprocessis 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.ymldecides.
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-sdkinstalls the same-versiondeepseek-harness-runtime-binplatform wheel, so the default entry point needs no executable argument — it launches the bundled single-filedsh-jsonrpc-agentexecutable. 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 thedsh-sdk-jsonrpc-serverentry 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"
- 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.
- Stdout purity — protocol frames own stdout, diagnostics go to stderr, and no logging can pollute programmatic parsing.
- Well-defined activity intervals —
run()(or your own combination ofsession.event+session.status) abstracts "send a prompt → wait until the whole agent is idle" into a single awaitable call, returningfinalResponseplus the full event/notification stream. - Complete lifecycle —
await using/ the context manager guarantees the child is reaped;close()walks the protocolshutdown+ EOF/SIGTERM/SIGKILL ladder to an idempotent terminal state. - Symmetric, two languages — TS and Python speak the same wire; pick either. The cross-process subagent backend (
dsh-sdksubagent 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.statusnotification stream (see the wire-methods table above), or use the high-levelrun(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}.mdandpython/sdk/README{.zh}.md.