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, 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. examples/jsonrpc-agent/cordis.yml is the reference deployment: 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.

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 app bin — the loader runner (dsh-jsonrpc-agent in packages/examples/jsonrpc-demo/src/runner.ts) handles stdin 'end', SIGTERM, SIGINT by disposing the root fiber and exiting.
  • 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 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 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
Example bin@deepseek-ai/dsh-sdk-jsonrpc-demo (dsh-jsonrpc-agent)
One-shot bundle@deepseek-ai/dsh-headless
CLI@deepseek-ai/dsh
The reference composition examples/jsonrpc-agent/package.json is a private example package (jsonrpc-agent-example) — not a published SDK artifact.

Further reading