Skip to content

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 ​

FieldValue
name@deepseek-ai/dsh-sdk-jsonrpc-server
roleStdio JSON-RPC server plugin for out-of-process SDK clients
proto peer@deepseek-ai/dsh-sdk-protocol
dependency@deepseek-ai/schemastery (Config typing)
peer depsdsh-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 ​

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

ts
// 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 shutdown request's handler runs, then setImmediate schedules disposeAndExit, which flushes the response, disposes the root context (ctx.root.fiber) so SDK-owned agents, subscriptions, and persistence reach quiescence, then exits with code 0. Protocol shutdown therefore owns the whole runtime process.
  • EOF and signal exits belong to the profile's startup provider: the sdk-app-startup app in packages/bundle/sdk-app/src/index.ts binds stdin EOF to the launcher's bounded successful shutdown (exitOnStdinEnd), while SIGTERM/SIGINT stay with the owning dsh launcher path. It also publishes a startup service the JSON-RPC server waits for, so dsh --profile sdk --help writes help and exits without claiming stdin/stdout.
  • Unloading only this plugin (e.g. via HMR) stops serving without exiting the process — the ctx.effect disposal path runs server.shutdown() then transport.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.

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

ts
// 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-official route mounts dsh-llm-deepseek as an llmFiber.

  • 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:

ts
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 eventNotification
session/eventsession.event (every session in the runtime, unfiltered)
agent/statussession.status (whole-agent running/idle)
session/created with a parentSessionsubagent.started
subagent/end when info.localsubagent.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:)DefaultMeaning
maxTokensAsSuccessfalseReport 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:

Aspectdsh-sdk-jsonrpc-server (persistent)dsh-headless (@deepseek-ai/dsh-headless)
Transportnewline-delimited JSON-RPC over stdionone — direct in-process Agent drive
Interaction modelmany session/prompt turns, sessions, subscribeexactly one task, one fresh Agent, exit
Outcomeno per-prompt result; events stream to the clientwrite last assistant text to stdout, exit 0/1
Compositionmounted by an external cordis.ymlcordis.patch.yml over dsh-base, no Host/HTTP
Exitshutdown 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 ​

LimitationConsequence
No per-session close or prompt-cancel on the wireSDK-created agents stay live until process shutdown
No per-prompt resultmessageId identifies inbox admission only; clients owning an automation interval define and observe that interval themselves
stdout purity is deployment-enforceda surrounding config can still load a stdout logger and corrupt the channel
Automatic adapter mounting is DeepSeek-specificinitialize reuses any pre-registered adapter, but its only fallback mounts dsh-llm-deepseek
In-process subagents onlysubagent.finished reports local child runs; remote runs are not reported

Package version table ​

Packagenameversion
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-headless at packages/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.