Developer reference, not a usage guide. It specifies how a terminal client talks to the DSCODE runtime, for anyone writing or debugging a client. Status: describes the protocol as implemented in 0.7.48; the code is authoritative where the two differ. Why it is shaped this way is in Runtime and client separation; the user-facing behaviour is in Background host and Remote runtime.
| Role | What it is | Code |
|---|---|---|
| Host | One Harness process per state directory. It owns sessions, agents, models, tools, permissions, credentials and persistence. | scripts/host.mjs, plugins/local-carrier/index.mjs, packages/tui/src/host/ |
| Client | A process that renders and drives sessions without owning any of them. The terminal is the only client DSCODE ships. | packages/tui/src/client.ts, packages/tui/src/runtime/typert.ts |
| Carrier | HTTP over an owner-only Unix socket between the two; dscode server adds the same routes over TCP with a bearer token. |
plugins/local-carrier/index.mjs (server), plugins/local-carrier/client.mjs (client) |
The protocol has two layers, both on the same socket:
- Typert, the upstream DSH RPC that the official Web and Desktop clients also use. It covers the session core: create, follow, page, fork, model selection, prompts, cancellation, approvals and questions.
- DSCODE operations, a registry of named methods and streams for everything DSCODE adds on top: slash commands, delivery with session references, queue edits, provider settings and login, catalogs,
/btw, child-session feeds, footer status and notices.
terminal client Host (Harness)
┌─────────────────────┐ host.sock (0600) ┌──────────────────────────────┐
│ RuntimeClient │ POST /api/<method> │ dsh-api-gateway (Typert) │
│ (runtime/typert) │ ────────────────────▶ │ │
│ │ POST /dscode/<name> │ tui-host registry (DSCODE) │
│ │ ────────────────────▶ │ │
│ │ POST /stream (NDJSON)│ wireStream / registry.open │
│ │ ◀───────────────────▶ │ │
└─────────────────────┘ └──────────────────────────────┘
In single-process mode (--in-process) the same RuntimeClient interface is implemented by runtime/in-process.ts, which reads live objects and invokes the same registry directly. Arguments and results go through JSON.parse(JSON.stringify(…)) there too, so a DSCODE operation behaves identically in both modes.
The socket is host.sock in the session bridge's per-state directory under /tmp (ensureSocketDirectory in plugins/session-bridge/paths.mjs), which keeps the path inside the macOS limit of about 104 bytes. The directory is checked for ownership and mode 0700; the socket is chmod 0600 once listening. Nothing listens on TCP. Filesystem permissions are the only authentication: any process running as the same user can connect, and every connection acts as the Host's operator peer.
Before replacing a stale socket, a Host takes an exclusive flock on host.lock beside it. A Host that cannot take the lock exits with code 3, so exactly one Host serves a state directory.
dscode server --port PORT runs the same Host with a second HTTP server on bind:port (default 0.0.0.0) carrying the same three routes. The Unix socket stays, unauthenticated as before, so terminals on the server attach locally.
- Authentication. Every TCP request must carry
Authorization: Bearer <token>. The token is read at start from thetokenFilethe server was given (run/server-token, 0600, minted asdsk_plus 24 random bytes in base64url) and compared in constant time. A token holder acts as the operator peer, exactly like a socket client. - Refusal. A missing or wrong token gets HTTP 401 before routing, in each route's own failure shape:
{"type": "server-response", "result": {"ok": false, "error": …}}on/api,{"ok": false, "error": …}on/dscode, and a single{"error": …}line on/stream. The code isdscode/unauthorized. Refused requests do not count as activity. - No encryption. The listener is plain HTTP. Confidentiality depends on the network (VPN, LAN, SSH tunnel).
- Long-lived streams.
requestTimeoutis disabled, because a stream's request body stays open for uplink items. TCP keepalive (30 s) is on at both ends, so an idle stream is not dropped by NAT. The client sends unary calls through a keep-alive agent and gives every stream its own unpooled connection.
A client names the server as {url: "http://host:port", token} instead of a socket path; plugins/local-carrier/client.mjs accepts either form everywhere (requestOptions, targetFromEnv).
All requests are HTTP/1.1 over the socket. There are three routes:
| Route | Used for | Request body | Response body |
|---|---|---|---|
POST /api/<namespace>/<method> |
Typert unary calls | Typert envelope (JSON) | Typert envelope (JSON) |
POST /dscode/<name> |
DSCODE unary operations; <name> is URI-encoded (host%2Fstatus) |
{"args": {…}} |
{"ok": true, "value": …} or {"ok": false, "error": {"code", "message"}} |
POST /stream |
Every stream, Typert or DSCODE | NDJSON, see below | NDJSON, see below |
Any other path is handed to the upstream shared fetch handler under /api.
→ {"type": "client-request", "rpcId": "<uuid>", "method": "session/create",
"payload": {"args": {"request": {"cwd": "/work", "agentPreset": "dscode"}}}}
← {"type": "server-response", "rpcId": "<same uuid>",
"result": {"ok": true, "value": {"sessionId": "…"}}}payload always has a single args object whose keys are the method's parameter names; most session methods take one parameter called request. A failure is {"ok": false, "error": {"code", "message"}} inside result. A client must check that rpcId matches.
The response is always HTTP 200; success and failure are distinguished by ok. A missing registry (a Harness without the tui-host plugin) answers dscode/unavailable; an unknown name answers dscode/unknown-method. Aborting the HTTP request aborts the operation's signal.
One logical stream is one POST /stream exchange with content-type: application/x-ndjson in both directions.
- Request: the first line opens the stream,
{"endpoint": "<endpoint>", "payload": {"args": {…}}}. Every later line is one uplink item, delivered to the stream in order. The client may keep the request body open for as long as it needs to send uplink items. - Response: zero or more
{"item": …}lines, then exactly one terminal line,{"end": true}or{"error": {"code", "message"}}. A response that closes without a terminal line is a broken stream. - An endpoint starting with
dscode/opens a DSCODE registry stream (dscode/status/watch); any other endpoint ($events,session/follow) opens a Typert stream throughtypertGateway.wireStream. - Closing the connection, from either side, aborts the stream.
| Operation | Form | Result |
|---|---|---|
host/status |
DSCODE unary | {version, pid, startedAt, socketPath, clients, streams, sessions, running, idleExitMs, defaultCwd, tcp?}; defaultCwd is the folder a remote client uses when it names none, tcp is {port, bind} on a server |
host/shutdown |
DSCODE unary | {stopping: true}; the Host exits about 50 ms later |
A launcher finds the Host by calling host/status with a 2-second timeout. If nothing answers, ensureHost (scripts/host.mjs) spawns dscode host serve detached, with output appended to run/host.log in the state directory, and polls host/status for up to 90 seconds. A started Host that exits early (it lost the lock race) is relaunched unless another Host has started answering. The launcher then starts the client with DSCODE_RUNTIME_SOCKET set to the socket path; the client refuses to start without it.
Idle accounting. clients counts open $events streams, one per attached client; streams counts all open streams. Every request except host/status refreshes the activity clock, so status probes never keep a Host alive. With idleExitMs > 0 (default 600000, from DSCODE_HOST_IDLE_MS), the Host exits once clients, streams and running agents have all been zero for that long.
Remote launch. For a remote runtime (dscode runtime <alias>, or a default other than local), the launcher starts no local Host. It calls host/status over TCP and refuses to continue if the server does not answer, rejects the token, or reports a different version. It then starts the client with DSCODE_RUNTIME_URL, DSCODE_RUNTIME_TOKEN, DSCODE_RUNTIME_CWD (the session folder on the server: --cwd, else the alias's folder, else the server's defaultCwd) and DSCODE_RUNTIME_ALIAS. There is no fallback to this machine.
Versioning. There is no protocol version number; client and Host must run the same DSCODE release. For a remote runtime the launcher compares host/status.version with its own before starting the client, and the client compares again after every reconnect and reports a mismatch. For the local Host, the launcher always starts its own package's Host, and dscode update stops an idle Host before replacing the package and refuses while a client is attached.
The sequence below is what createTypertRuntime (packages/tui/src/runtime/typert.ts) does. Another client should follow the same order.
Open stream $events with payload {"args": {}} and keep it open for the client's whole lifetime. Its first item is {"type": "ready", "clientId": "…"}; the client needs the clientId to answer anything. Later items:
Item type |
Meaning |
|---|---|
emit |
One-way event {event, args}. The terminal uses api-session/status ([sessionId, running]), api-session/added ([{sessionId, agentAvailable, running}]) and api-session/removed ([sessionId]). |
waterfall |
An interactive request that needs an answer: {event, eventId, agentId, request}. See Approvals and questions. |
cancel |
{eventId}: the Host withdrew a pending waterfall, either because the turn ended or because another client answered it. |
This stream is also how the Host counts the client as attached.
New session. Call Typert session/create with {request: {sessionId?, cwd, agentPreset}}. The terminal mints the sessionId itself; when it is omitted, the Host mints one and returns {sessionId}. cwd fixes the session's folder, and with it the folder's AGENTS.md/CLAUDE.md, ancestor skill roots and project hooks. Then, optionally:
session/selectModelwith{request: {sessionId, provider, model, reasoningEffort?}}.- DSCODE
permissions/selectwith{sessionId, id}for a permission preset chosen before the session existed.
Resume. No call is needed: opening session/follow on a persisted session makes the Host resume it. To turn user input (an id or an id prefix) into a session id, call DSCODE sessions/resolveResume with {wanted, currentId?, allowLive: true}. allowLive: true means "attach to the live session"; without it, a session that is live elsewhere is refused with dscode/live-elsewhere (the single-process rule). Subagent sessions are refused with dscode/read-only.
Fork. Typert session/fork with {request: {sessionId, atSeq?}} returns {sessionId} of the fork, which is then opened like a resume.
Open stream session/follow:
{"endpoint": "session/follow", "payload": {"args": {"request": {
"address": {"kind": "session", "sessionId": "…"},
"assistantStream": true, "maxMessages": 1000}}}}Item type |
Contents |
|---|---|
snapshot |
First item: header, records ([{event}], the newest page, oldest first), cursor and hasMore |
event |
event: one committed session event, in sequence order |
assistant-stream |
frame: one live, uncommitted assistant presentation frame (text deltas and the like) |
Older history. While hasMore is true, call Typert session/page with {request: {address, throughSeq: cursor, beforeSeq: <seq of the oldest event held>, maxMessages}}. It returns {records, hasMore}; prepend the records. The terminal pages back to the start because it folds the whole log (transcript, fork points, history search).
Running state. Running state changes arrive as turn/start and turn/end events and as api-session/status emits. A client attaching mid-turn reads it once from Typert session/list ({_request: {}} returns {items: [{sessionId, running, …}]}), so it can show and interrupt the turn it joined.
Child sessions. Subagent and /btw sessions are not on the root's follow stream. Open DSCODE stream sessions/children with {sessionId: <root>}. Its items are {kind: "event", sessionId, header, event} for committed child events and {kind: "stream", agentId, frame} for live child frames. Only events from after the stream opened are delivered.
Agent-scoped interactive requests arrive on $events as waterfall items. The terminal handles two of them:
event |
request fields |
Answer value |
|---|---|---|
approval/request |
toolName, callId, reason, displayReason |
"allowed-once", "rejected", "cancelled" or "unavailable" |
user-questions/request |
the ask_user_question request (questions, options, plan review) | an AskUserQuestionAnswer |
The approval request does not carry the tool's arguments. Its callId matches the tool/call event on the follow stream, which does.
Answer through Typert unary $events/result with {clientId, eventId, outcome}, where outcome is one of:
{"kind": "result", "value": …}: this client answers.{"kind": "next"}: this client declines and passes the request on. Use this for anyeventit does not handle and for agents it does not drive (the terminal treats the rootsessionIdas theagentIdit drives).{"kind": "rejected", "error": {"name", "message", "code?"}}: answering failed.
A pending waterfall goes to every attached client and is replayed to a client that reconnects. The first answer wins. The others receive cancel for that eventId and must close their prompt without answering.
The terminal sends input and control through DSCODE operations, which carry DSCODE behaviour that upstream session/prompt does not:
| Operation | Args | Effect |
|---|---|---|
session/deliver |
{sessionId, content, mode, context?} |
content is an array of content blocks; mode is "followup" (queue for the next turn, or start one) or "steer" (into the running turn). context is the model-facing user message that mentions/prepare produced for a session reference, injected ahead of the readable message. |
session/updateQueued |
{sessionId, messageId, action} |
Change a queued next-turn message; action is {kind: "edit", text}, {kind: "remove"} or {kind: "steer"} (move it into the running turn) |
session/interrupt |
{sessionId} |
Cancel the running turn but keep and re-wake the next-turn queue; returns {cancelled, preserved} |
commands/execute |
{sessionId, line} |
Run a slash command line; returns {handled} |
Upstream session/prompt ({request: {requestId, sessionId, mode: "queue" \| "steer", content}}) and session/cancel also work on the Host, and scripts/verify-local-carrier.mjs exercises session/prompt. A minimal client may use them instead.
Operations on a session need a live agent on the Host; otherwise they fail with dscode/session-not-live. Following the session first makes it live.
Abort the follow, children and watch streams. The session stays on the Host, and a running turn continues. Close $events last; the Host then stops counting the client. Nothing has to be flushed, because the Host owns persistence.
A stream that fails with a connection error, or ends without being aborted, is reopened with backoff (250 ms, 500 ms, 1 s, 2 s, then every 5 s). A failure that carries a Remote or dscode/* code is the operation's own and is not retried.
$events. A reopened stream gets a newreadywith a newclientId. Prompts still open from the old connection are withdrawn, because the Host replays every waterfall that is still pending. An answer that could not be delivered is kept and sent when itseventIdis replayed. The terminal shows the drop and the recovery as notices, and the session streams below retry at once instead of finishing their backoff.session/follow. The reopened stream opens with a freshsnapshot. The client keeps what it has folded, takes the snapshot's events newer than the lastseqit saw, and pages back withsession/page(throughSeq= the newcursor,beforeSeq= the oldest new record) until it reaches thatseq. It then emits the missed events in order and re-reads the running state fromsession/list. Live assistant frames from the gap are not recovered; the committed message carries the text.- Watches and feeds (
commands/watch,models/defaults,skills/watch,status/watch,notices/watch,sessions/children, revision streams) reopen the same way. A revision stream's first item after a reopen counts as a change. Child events committed during the gap are not replayed. - Unary calls made while disconnected fail and are reported. Opening a session or delivering a message is not retried automatically.
- Every client follows the same session independently and sees the same committed events in the same order.
- Live
assistant-streamframes are delivered only from the moment a client's follow stream opens. Text streamed before that appears once the assistant message is committed. - One-way
emitevents are not replayed. State a reconnecting client needs must come from a query (session/list), the follow snapshot or a watch stream's opening value. - Clients on the local socket and clients over TCP are equal. A remote and a local terminal can share one session on a server.
- Waterfalls follow the first-answer rule above.
- Input from every client goes into the session's one inbox; there is no per-client lock or ownership of a turn. Any client can interrupt a turn another client started.
Unary methods are POST /dscode/<name>; streams are endpoint dscode/<name>. Argument and result types live in the named source files under packages/tui/src/host/.
| Group | Methods | Streams | Source |
|---|---|---|---|
| Host | host/status, host/shutdown |
plugins/local-carrier/index.mjs |
|
| Delivery | session/deliver, session/updateQueued, session/interrupt |
delivery.ts |
|
| Commands | commands/execute |
commands/watch {sessionId?} |
commands.ts |
| Sessions | sessions/list, sessions/subagents, sessions/delete, sessions/transcript, sessions/search, sessions/usage, sessions/rename, sessions/resolveResume |
sessions.ts |
|
Children and /btw |
btw/start {sessionId, childId, question, mode}, btw/dispose {ids} |
sessions/children {sessionId} |
children.ts |
| Models | models/directory, models/ensureRoute, models/save, models/compactionPreview, models/subagentOverride |
models/defaults |
models.ts |
| Providers | providers/directory, providers/saveCredential, providers/saveConfiguration, providers/discover, providers/unsetCredential, providers/remove |
providers/watch |
providers.ts |
| Login | authorization/directory, authorization/cancel, authorization/logout |
authorization/watch, authorization/begin |
providers.ts |
| OpenRouter | openrouter/managementKeyStatus, openrouter/saveManagementKey, openrouter/account |
providers.ts |
|
| Custom providers | custom/available, custom/list, custom/save, custom/remove, custom/discover, custom/test |
providers.ts |
|
| Catalogs | jobs/list, schedules/list, plugins/list, presets/planAvailable |
skills/watch {sessionId?, cwd} |
catalog.ts |
| Permissions | permissions/rows, permissions/names, permissions/effective, permissions/select |
catalog.ts |
|
| Mentions | mentions/candidates {sessionId?, cwd, query}, mentions/prepare {sessionId?, cwd, parsed} |
catalog.ts |
|
| Attachments | attachments/inspectImages, attachments/saveImages, attachments/inspectFiles, attachments/saveFiles (paths the Host reads); attachments/inspectImageData, attachments/saveImageData, attachments/inspectFileData, attachments/saveFileData (content a remote client read) |
catalog.ts |
|
| Workspace | workspace/gitDiff {cwd, argument}, workspace/commitDiff {cwd, sha}, workspace/mergeBase {cwd, branch}, workspace/reviewBranches, workspace/reviewCommits, workspace/gitBranch {cwd}, workspace/writeText {path, text} |
workspace.ts |
|
| Footer and notices | status/watch {sessionId, modelLabel}, notices/watch |
status.ts, notices.ts |
Content attachments. A client that does not share the Host's filesystem (RuntimeClient.sharesFilesystem is false over TCP) sends a path it can read itself as content. The …Data inspect methods take {items: [{path, name, bytes, signature}]}, where signature is the base64 of the file's first 16 bytes. The save methods take {items: [{name, data}]} with the whole file in base64. Both apply the same count, size and format limits as the path methods. The client passes any path it cannot read to the path methods, and the Host resolves it in the session's folder. workspace/* always runs on the Host, so /diff, /review, /export and the status-line branch read the session's folder on that machine.
models/subagentOverride sets one Host-wide override shared by every client: /subagent in one terminal changes delegated agents' models for all sessions on that Host.
- Value watches (
commands/watch,models/defaults,skills/watch) send the current value first, then a new value on every change, skipping values that serialize identically to the previous one. - Revision watches (
providers/watch,authorization/watch) send an increasing revision number; the client re-reads the matching…/directorymethod when it changes. The first item is the opening revision, not a change. - Samplers:
status/watchsamples metrics, the service-speed label and the delegation board every 500 ms and sends only changes. - Event feeds (
sessions/children,notices/watch) send only what happens after they open. - Interactive login (
authorization/begin{key, method}) uses the uplink. The Host sends{type: "notify", notice},{type: "prompt", id, prompt}and finally{type: "done", status}. The client answers each prompt by writing{type: "answer", id, value}or{type: "decline", id}as an uplink line. Secrets typed at a prompt cross the socket only as that uplink line.
DSCODE failures carry a stable code; anything that is not a deliberate failure arrives as dscode/internal with the error message.
| Code | Meaning |
|---|---|
dscode/unauthorized |
A TCP request without the server's token |
dscode/unavailable |
The Host has no DSCODE registry mounted |
dscode/unknown-method, dscode/unknown-stream |
No such operation |
dscode/session-not-live |
The session has no live agent on this Host |
dscode/not-found, dscode/ambiguous, dscode/usage |
sessions/resolveResume could not resolve the input |
dscode/read-only |
A subagent session cannot be resumed as a root |
dscode/live-elsewhere |
The session is live and allowLive was not set |
dscode/*-unavailable |
The composition lacks the service (models, commands, schedule, permissions, custom providers, session query, /btw) |
Typert failures follow the upstream RemoteFailure codes. A stream's failure arrives as its terminal {error} line.
plugins/local-carrier/client.mjs is a dependency-free Node client for the carrier: call (Typert unary), callDscode, openStream and openDscodeStream. The following script creates a session, prints the reply as it streams and rejects every approval:
import { randomUUID } from 'node:crypto';
import { call, callDscode, openStream } from './plugins/local-carrier/client.mjs';
const socket = process.env.DSCODE_RUNTIME_SOCKET;
const value = result => { if (!result.ok) throw Error(result.error.message); return result.value; };
// 1. Attach.
let clientId;
void (async () => {
for await (const frame of openStream(socket, '$events', { args: {} })) {
if (frame.type === 'ready') clientId = frame.clientId;
if (frame.type === 'waterfall') await call(socket, '$events/result', {
clientId, eventId: frame.eventId,
outcome: frame.event === 'approval/request' ? { kind: 'result', value: 'rejected' } : { kind: 'next' },
});
}
})();
// 2. Open.
const { sessionId } = value(await call(socket, 'session/create', { request: { cwd: process.cwd(), agentPreset: 'dscode' } }));
// 3. Follow, then 5. deliver.
void (async () => {
const request = { address: { kind: 'session', sessionId }, assistantStream: true };
for await (const frame of openStream(socket, 'session/follow', { args: { request } })) {
const live = frame.type === 'assistant-stream' ? frame.frame : undefined;
if (live?.type === 'chunk' && live.chunk.type === 'text-delta') process.stdout.write(live.chunk.text);
if (frame.type === 'event' && frame.event.type === 'turn/end') process.exit(0);
}
})();
await callDscode(socket, 'session/deliver', { sessionId, mode: 'followup', content: [{ type: 'text', text: 'hello' }] });The socket path of a running Host is the socketPath field of dscode host status. For a remote server, pass { url: 'http://host:port', token } wherever the example passes socket. Assistant frames are @deepseek-ai/dsh-agent's AssistantStreamFrame: start, then chunk frames carrying an LLM StreamChunk (text-delta, reasoning-delta, tool-call-delta, …), then end with the committed event's seq or abandoned. Check them against the pinned DSH version.
scripts/verify-local-carrier.mjsdrives a headless Host from a separate process with upstream methods only: create, select model, prompt, follow with live frames, and an approval rejected through$events/result.scripts/verify-host.mjscovers two clients on one session, a client killed mid-turn and reattached through paged history, concurrent Host starts, and idle exit.scripts/verify-tui-runner.mjsruns the real terminal in single-process, same-process protocol and separate-Host modes.scripts/verify-remote.mjsruns a realdscode serverand a terminal with separate state directories: token refusal on every route,dscode config, the full terminal scenario over TCP,/exportand the git branch in the server's folder, a server restart mid-session with the terminal reconnecting, content attachments, and refused launches.tests/local-carrier.test.mjscovers the envelopes, socket permissions, uplink, failure mapping, client abort, bearer-token checks on every TCP route and the version refusal.tests/remote-runtime.test.mjscovers the runtime configuration, the server token, content-attachment routing and limits, and stream reopening.
Not covered by an automated case: two clients answering the same waterfall at once, a waterfall answered across a reconnect, a client and server on different machines (the verifier uses loopback), and a long idle stream crossing real NAT.