The server is the runtime-side half of the SDK. It is a Cordis plugin, named sdk-jsonrpc-server, implemented by @deepseek-ai/dsh-sdk-jsonrpc-server. It serves the wire protocol over stdio so out-of-process SDK clients (TypeScript and Python) can drive harness agents headlessly — no terminal UI, no approval surface, no listening port. This page cites packages/sdk/server/src and the surrounding composition.
Package and version
| Field | Value |
|---|---|
| name | @deepseek-ai/dsh-sdk-jsonrpc-server |
| role | Stdio JSON-RPC server plugin for out-of-process SDK clients |
| proto peer | @deepseek-ai/dsh-sdk-protocol |
| dependency | @deepseek-ai/schemastery (Config typing) |
| peer deps | dsh-agent, dsh-llm, dsh-llm-deepseek, dsh-scope, dsh-session, dsh-subagent, dsh-sdk-protocol, dsh-invariants, dsh-attachment, plus cordis |
The server re-exports HarnessSdkJsonRpcServer from ./server.ts, and src/index.ts declares the plugin (name, inject, Config, apply). Named exports only — no default export — so the Loader's unwrapExports preserves the plugin shape.
The plugin contract
// packages/sdk/server/src/index.ts (abridged)
export const name = 'sdk-jsonrpc-server'
export const inject = ['agents'] // only the Agent factory is required
export interface JsonRpcConfig {
maxTokensAsSuccess?: boolean // report max-token termination as 'ok'
input?: Readable // runtime-only hook; production = process.stdin
output?: Writable // runtime-only hook; production = process.stdout
exit?: (code: number) => void // runtime-only hook; production = process.exit
}
export const Config: Schema<JsonRpcConfig> = Schema.object({
maxTokensAsSuccess: Schema.boolean().default(false),
})input/output/exit are runtime-only transport hooks for tests; production uses process stdio and process.exit. Only maxTokensAsSuccess is settable from cordis.yml.
The surrounding cordis.yml decides whether the plugin is loaded at all. packages/bundle/sdk-app is the reference deployment (dsh --profile sdk): it stacks the server next to the DeepSeek adapter, a bash executor, the agent spine, JSONL persistence, compaction, and an in-process subagent spawn provider; bundle/sdk-minimal (dsh --profile sdk-minimal) is the standalone two-tool counterpart.
Stdout IS the protocol
Stdout carries only JSON-RPC frames. The deployment must not compose a stdout logger; diagnostics belong on stderr. This is enforced by composition, not by inspection — the plugin does not veto sibling loggers, so a surrounding config that loads a stdout logger will corrupt the channel.
Server lifecycle
apply wires one JsonRpcLineTransport over the chosen streams and constructs the server:
// packages/sdk/server/src/index.ts (abridged)
const transport = new JsonRpcLineTransport(input, output)
const server = new HarnessSdkJsonRpcServer(ctx, transport, {
maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
})
transport.onRequest(async (method, params) => {
const result = await server.handleRequest(method, params)
if (method === 'shutdown') setImmediate(() => { void disposeAndExit() })
return result
})
ctx.effect(() => {
transport.start()
return async () => { await server.shutdown(); transport.close() }
}, 'jsonrpc.serve')Shutdown semantics are precise:
- The
shutdownrequest's handler runs, thensetImmediateschedulesdisposeAndExit, which flushes the response, disposes the root context (ctx.root.fiber) so SDK-owned agents, subscriptions, and persistence reach quiescence, then exits with code0. Protocol shutdown therefore owns the whole runtime process. - EOF and signal exits belong to the profile's startup provider: the
sdk-app-startupapp inpackages/bundle/sdk-app/src/index.tsbinds stdin EOF to the launcher's bounded successful shutdown (exitOnStdinEnd), whileSIGTERM/SIGINTstay with the owningdshlauncher path. It also publishes a startup service the JSON-RPC server waits for, sodsh --profile sdk --helpwrites help and exits without claiming stdin/stdout. - Unloading only this plugin (e.g. via HMR) stops serving without exiting the process — the
ctx.effectdisposal path runsserver.shutdown()thentransport.close().
A shared exitTask ensures racing shutdown requests cannot dispose the root or exit more than once.
HarnessSdkJsonRpcServer
The class (src/server.ts) owns the protocol methods, session registry, and event re-projection. Construction subscribes to session/agent/subagent lifecycle events until shutdown; reinitialization is unsupported.
export class HarnessSdkJsonRpcServer {
async initialize(params: InitializeParams): Promise<InitializeResult>
async prompt(params: SessionPromptParams): Promise<SessionPromptResult>
shutdown(): Promise<Record<string, never>>
async handleRequest(method, params): Promise<unknown> // dispatch switch
// private: getOrCreateSession / createSession / hasAdapterFor
}handleRequest is the JSON-RPC dispatch switch: initialize, session/prompt, shutdown, else unknown DeepSeek Harness SDK runtime method.
Model + agent-loop wiring
Each SDK session maps to one live agent registered on ctx.agents. getOrCreateSession is a memoizing factory that dedupes in-flight creations:
// packages/sdk/server/src/server.ts (abridged)
private async createSession(sessionId: string): Promise<SessionRecord> {
const handle = await this.ctx.agents.create({
sessionId: SessionId(sessionId),
meta: { cwd: this.cwd },
agentOptions: {
provider: this.provider,
model: this.model,
...(this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }),
},
})
// record the handle in this.sessions
}initialize treats the SDK handshake as the readiness boundary and validates the whole route before pinning it:
The handler first awaits Loader settlement —
await ctx.get('loader')?.await()(packages/sdk/server/src/index.ts): this plugin can activate before async sibling Loader entries (for example an MCP client's initial tool discovery), so the runtime is not advertised as ready until the complete current tree has settled. A hand-built context without Loader stays immediately usable.Then it picks the provider/model route and handles the adapter seam:
If a registered adapter already serves the requested provider, it is reused.
An unowned
deepseek-officialroute mountsdsh-llm-deepseekas anllmFiber.Any other unowned provider fails initialization.
The full route is then validated up front via llm.resolveCallConfig({ provider, model, …reasoningEffort, …maxTokens }) (packages/sdk/server/src/server.ts) — an invalid provider/model/reasoning-effort combination rejects initialization, and only a validated route is pinned as this.provider/this.model/this.reasoningEffort/this.maxTokens.
The agent factory is the only hard inject; the optional LLM seam is read via ctx.get('llm') (hasAdapterFor lists providers). The composition keeps the model-facing rows in the host plane, so SDK-created agents read them from the global layer rather than a preset roster (per the agent-presets README note about composing a child agent).
prompt validates the live registry before delivery (an agent-loop-only reload can dispose an agent while this record survives, in which case the SDK agent no longer matches ctx.agents.get(...)), then enqueues:
const message = createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } })
rec.handle.agent.followup(message)
return { messageId: message.id }This is the "queue one identified user message, return immediately" receipt. followup wakes the agent loop; the response has no per-prompt result.
Event re-projection
The constructor registers four ctx.on subscriptions that fan out to notifications:
| Cordis event | Notification |
|---|---|
session/event | session.event (every session in the runtime, unfiltered) |
agent/status | session.status (whole-agent running/idle) |
session/created with a parentSession | subagent.started |
subagent/end when info.local | subagent.finished |
The locality rule for subagent.finished is subtle: the service snapshots the run's local flag (via subagentParentOf recovering the delegating parent from the scoped carrier Scoped<SubagentRuntime> with carrierKeyOf), and the notification is only emitted for in-process child sessions. Provider names, child ids, and durable lineage never establish locality by themselves.
Config keys
Key (cordis.yml, config:) | Default | Meaning |
|---|---|---|
maxTokensAsSuccess | false | Report max-tokens termination as status: 'ok' on subagent.finished; root-session prompts have no prompt-level status |
initialize.maxTokens (a wire param, not a config key) is an optional positive output-token cap inherited by SDK-created agents and their in-process descendants; invalid values reject initialization, and omission sends no SDK cap so the adapter or provider route default applies.
One-shot vs persistent: dsh-headless cross-reference
The SDK server is one of two "headless server-side" patterns in the repo. Contrast:
| Aspect | dsh-sdk-jsonrpc-server (persistent) | dsh-headless (@deepseek-ai/dsh-headless) |
|---|---|---|
| Transport | newline-delimited JSON-RPC over stdio | none — direct in-process Agent drive |
| Interaction model | many session/prompt turns, sessions, subscribe | exactly one task, one fresh Agent, exit |
| Outcome | no per-prompt result; events stream to the client | write last assistant text to stdout, exit 0/1 |
| Composition | mounted by an external cordis.yml | cordis.patch.yml over dsh-base, no Host/HTTP |
| Exit | shutdown request, EOF, or signal (bin-owned) | launcher-provided ctx.appExit hook |
dsh-headless (packages/bundle/headless/src) creates one Agent through the core registry, drives the task to quiescence, flushes its Session, prints the final assistant text, and exits through the launcher-provided appExit host hook — never opening a listening port. It is the "one-shot" sibling; the SDK server is the "persistent, protocol-driven" sibling. apps/cli (@deepseek-ai/dsh) depends on dsh-headless for the dsh --profile headless "<task>" one-shot mode.
Known limitations
| Limitation | Consequence |
|---|---|
| No per-session close or prompt-cancel on the wire | SDK-created agents stay live until process shutdown |
| No per-prompt result | messageId identifies inbox admission only; clients owning an automation interval define and observe that interval themselves |
| stdout purity is deployment-enforced | a surrounding config can still load a stdout logger and corrupt the channel |
| Automatic adapter mounting is DeepSeek-specific | initialize reuses any pre-registered adapter, but its only fallback mounts dsh-llm-deepseek |
| In-process subagents only | subagent.finished reports local child runs; remote runs are not reported |
Package version table
| Package | name | version |
|---|---|---|
| SDK server | @deepseek-ai/dsh-sdk-jsonrpc-server | |
| SDK wire protocol (peer) | @deepseek-ai/dsh-sdk-protocol | |
| SDK app bundle | @deepseek-ai/dsh-sdk-app (dsh --profile sdk) | |
| SDK minimal bundle | @deepseek-ai/dsh-sdk-minimal (dsh --profile sdk-minimal) | |
| One-shot bundle | @deepseek-ai/dsh-headless | |
| CLI | @deepseek-ai/dsh | |
The reference deployment is the published sdk-app bundle (dsh --profile sdk), not a private example package. |
Further reading
- SDK Protocol — the framing, methods, and types this plugin serves.
- SDK Client — the other end of the wire.
- SDK: The Wire Protocol → shutdown semantics and the app startup provider at
packages/bundle/sdk-app/src/index.ts. - One-shot contrast:
@deepseek-ai/dsh-headlessatpackages/bundle/headless/src/startup.ts. - Runtime, agent, and session concepts: Runtime & Agent Lifecycle and Session Management.
- Repo-relative:
packages/sdk/server/src/server.ts,packages/sdk/server/src/index.ts,packages/bundle/sdk-app/cordis.patch.yml,packages/bundle/sdk-minimal/cordis.patch.yml.