dsh-api-gateway

September 1, 2026 · View on GitHub

English | 中文

A plugin for DeepSeek Harness that turns a running Harness into an HTTP API: any third-party client — curl, Python, a browser, an IM bridge — can create agent sessions, stream replies token-by-token over SSE, and continue conversations started in the Web UI, all behind API-key authentication. API sessions drive the same agent machine the GUI drives (inbox + session log), so both worlds stay in sync.

dsh plugin --profile web add github:litestartup-com/dsh-api-gateway

Features

  • REST + SSE: 10 endpoints; token-level streaming (assistant/chunk), server closes the stream at turn_end
  • GUI settings card: Settings → Plugins → Configurable → dsh-api-gw (collapsed by default, discloses via the chevron; status, soft on/off, key rotation). English by default, Chinese when the page or browser asks for it
  • Workspace membership: API sessions land in real workspaces and show grouped in the sidebar, never under "ungrouped"
  • Session discovery & adoption: list all sessions, read any session's full history (read-only), and adopt a GUI session to keep driving it over the API — live co-driving or cold resume with full context
  • Reasoning split: replies separate text (visible answer) from reasoning (thinking), never concatenated
  • No silent blocking: interactive question cards are handed back to the model as a conversational question, so a turn never stalls on a card nobody can click; permission prompts are relayed as approval_asked / approval_decided frames instead of looking like a slow turn
  • Extensible: publishes gateway/session-created / gateway/session-released / gateway/message / gateway/turn-end on the Cordis event bus for other host plugins
  • Any language client: works from Linux/macOS/Windows, PowerShell included (UTF-8 aware, GBK-tolerant server side)

Install

# from GitHub (prebuilt lib/ committed — no build approval needed)
dsh plugin --profile web add github:litestartup-com/dsh-api-gateway

# from a packed tarball
dsh plugin --profile web add ./dsh-api-gateway-0.1.0.tgz

The built lib/ is committed, so GitHub installs need no build approval. Build scripts run only when packing or publishing (prepack).

Uninstall: dsh plugin --profile web remove dsh-api-gateway.

Manual composition row (no CLI)

The plugin is an ordinary Cordis row; you can also compose it by hand. It publishes a cross-session HTTP surface, so it belongs in the host composition (or the profile's patch layer) — never inside an agent preset:

- id: dsh-api-gw
  name: dsh-api-gateway
  config:
    prefix: /api-gw/v1          # route prefix
    enabled: true               # master switch (also toggleable at runtime)
    apiKeys: []                 # pre-provisioned static API keys
    allowKeyProvision: true     # one-time POST /key bootstrap (only while no key exists)
    # provisionedKey            # written by the gateway itself -- do not set by hand
    adminKey: change-me         # enables admin endpoints + card controls
    maxSessions: 20             # concurrent session cap
    workspaceMode: auto         # auto (join a workspace) | ungrouped
    defaultWorkspacePath: ''    # fallback directory for auto mode
    allowDiscover: true         # GET /sessions/discover
    allowAdopt: true            # POST /sessions/:id/adopt
    corsOrigin: '*'             # '*' or an explicit origin / list (list is matched against the request Origin)
    exposeErrors: true          # include internal details in error responses
    questions: host             # host (leave it to the deployment's UI) | gateway (relay to API clients)
    approvals: host             # host | gateway (relay permission prompts to API clients)
    sseHeartbeatMs: 30000       # SSE heartbeat interval (0 disables)
    bodyTimeoutMs: 30000        # request body read timeout

Every key has a schema default — see examples/cordis.yml for the annotated row.

Quick start

With DSH running, ask an agent something. The script claims the API key, opens a session, sends the prompt and prints the reply token by token:

./examples/ask.py "introduce yourself"     # any OS, stdlib only
.\examples\ask.ps1 "introduce yourself"    # Windows-native, no extra tools

Drop the prompt for interactive mode (many turns, one session). --help lists everything; the flags you'll actually reach for:

FlagMeaning
-s <id>talk to an existing session — including one open in the GUI
-llist every session the gateway can see
--no-streamskip SSE, poll for the final answer
-c <path>working directory (and therefore workspace) of a new session

The raw protocol, if you'd rather see the wire:

BASE=http://127.0.0.1:3080/api-gw/v1
KEY=$(curl -s -X POST $BASE/key | jq -r .apiKey)                       # claimable once ever; the key itself is durable
SID=$(curl -s -X POST $BASE/sessions -H "Authorization: Bearer $KEY" | jq -r .sessionId)
curl -sN $BASE/sessions/$SID/stream -H "Authorization: Bearer $KEY" &   # attach before asking
curl -s -X POST $BASE/sessions/$SID/messages -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{"content":"hello"}'         # 202 accepted

Client examples

Three readable, self-documenting clients — same flags, same behaviour:

ScriptNeedsNotes
examples/ask.pyPython 3.8+stdlib only; the reference client
examples/ask.ps1PowerShell 5.1+UTF-8 safe on Windows
examples/ask.shbash 4+, curl, jq

Two things they get right that ad-hoc snippets often don't:

  • Attach the stream before sending. The server ends a stream at turn_end, so a client that attaches after the turn finished waits forever; attaching early is free because the first hello frame replays the history. Missed the turn entirely? Read GET /sessions/:id/history.
  • Declare the charset. The server decodes bodies per the request Content-Type (UTF-8 default, GBK-tolerant). PowerShell 5.1 otherwise sends ANSI/GBK and mangles non-ASCII prompts, and curl.exe -d '{"a":"b"}' under PowerShell 5.1 loses the inner quotes (invalid JSON → 400, silenced by -s). Send UTF-8 bytes, or --data-binary "@file".

Endpoints

MethodPathAuthDescription
GET/healthnoneStatus (reachable while disabled)
POST/keyfirst call only (no key set)Bootstrap an API key; persisted, then closed for good
POST/sessionsAPI keyCreate a session (provider/model/maxTokens/cwd/workspace)
GET/sessions/discoverAPI keyList sessions (id/title/cwd/live/persisted) — no content
POST/sessions/:id/adoptAPI keyAdopt an existing session (live co-drive / resumed cold-resume); returns full history
POST/sessions/:id/messagesAPI keySend a message (string or block array)
GET/sessions/:id/streamAPI keySSE: hello(replay + open asks)→chunk→message→tool_call/tool_result→question_asked/approval_pending→turn_end
GET/sessions/:id/historyAPI keyFull history of any session (read-only)
GET/sessions/:id/questionsAPI keyQuestions awaiting an answer, and who owns answering (answeredBy)
POST/sessions/:id/questions/:questionId/answerAPI keyAnswer a question, unblocking the tool call
POST/sessions/:id/questions/:questionId/cancelAPI keyDecline it: the tool call fails and the turn goes on
GET/sessions/:id/approvalsAPI keyPermission prompts awaiting a decision
POST/sessions/:id/approvals/:decisionId/decideAPI keyallowed-once or rejected
POST/sessions/:id/cancelAPI keyCancel the active turn
DELETE/sessions/:idAPI keyRelease the session's maxSessions slot — history is kept
POST/admin/enableAdmin keyRuntime soft switch {"enabled": bool}
POST/admin/rotate-keyAdmin keyRotate provisionedKey (leaves apiKeys untouched)

Auth headers, either form: Authorization: Bearer <key> (recommended, RFC 6750) or X-API-Key: <key>.

Full spec: openapi.yaml.

Releasing sessions

maxSessions caps how many sessions this gateway holds, and creation fails once the cap is reached. DELETE /sessions/:id gives a slot back, which matters for clients that open a session per task: without it a long-running deployment reaches the cap and then cannot create or adopt anything until the gateway is reloaded.

What it does and does not do:

Frees the maxSessions slotyes
Ends open SSE streams for that sessionyes
Disposes the agentonly when this gateway owns it (created / resumed)
Touches a co-driven GUI session (live)no — it stops tracking, the Web UI keeps its session
Deletes the transcriptnoGET /sessions/:id/history keeps working and POST /sessions/:id/adopt brings the session back

It is idempotent: releasing an unknown or already-released id answers 200 with released: false, so a client can call it unconditionally in a cleanup path. A turn still in flight is cancelled first.

// DELETE /api-gw/v1/sessions/<id>
{ "ok": true, "sessionId": "...", "released": true, "disposed": true, "mode": "created", "historyRetained": true }

Interactive asks (questions and approvals)

An agent has two ways to stop and wait for a human: ask_user_question, which the model chooses, and a permission prompt, which the runtime raises mid-tool-call. Both block the turn until someone answers. The gateway's job is not to change that — it is to let the someone be an API client instead of a browser.

Nothing here touches the agent, the tool or the model. ask() still blocks the tool call and still returns a human answer; the only thing that moves is where that human sits.

Questions: questions: gateway

The question is relayed as a frame, and answered over HTTP:

// SSE, no `seq` -- this is live negotiation, not a durable log entry
{ "kind": "question_asked", "sessionId": "...", "questionId": "apigw-q-…",
  "questions": [ { "id": "q1", "question": "Drop the old rows?",
                   "options": [ { "label": "Keep" }, { "label": "Drop", "description": "irreversible" } ] } ] }
curl -X POST "$GW/sessions/$SID/questions/$QID/answer" -H "Authorization: Bearer $KEY" \
  -H 'content-type: application/json' -d '{"answers":[{"id":"q1","selected":["Keep"]}]}'

Every asked question must be answered and every label must be one that was offered (or accompanied by custom free text) — the answer becomes a tool result the model acts on, so a partial or invented one is refused with 400 and the question stays open. POST …/questions/:id/cancel declines instead: the tool call fails, which the model can act on. question_resolved closes the ask for every listener, so the first answer wins.

Ownership is an offer, not a seizure. The userQuestions slot holds exactly one provider and a second registration throws — and the browser UI's backend does not guard its own call, so a gateway that grabbed the slot first would take the whole GUI down with it. So the gateway asks for the slot only when its first session appears (by then the host tree is up, so it always loses a contested slot) and stands down quietly when it is taken. GET /sessions/:id/questions reports who actually owns it as answeredBy.

To free the slot, disable the @deepseek-ai/dsh-host-apiproxy row in the profile. That is the browser UI's backend — not the HTTP carrier (@deepseek-ai/dsh-host-webserver), so the gateway keeps serving normally; what you give up is the local browser UI for that profile.

Approvals: approvals: gateway

Permission prompts need no slot, because approval/request is a waterfall and answerers compose — this can be turned on in a profile that also serves the GUI.

{ "kind": "approval_pending", "decisionId": "apigw-ap-…", "toolName": "write_file", "callId": "…", "reason": "…" }
curl -X POST "$GW/sessions/$SID/approvals/$DID/decide" -H "Authorization: Bearer $KEY" \
  -H 'content-type: application/json' -d '{"outcome":"allowed-once"}'

Only allowed-once and rejected are accepted, and allowed-once is the vocabulary's sole grant: it covers the exact call being decided. There is deliberately no way from here to widen a session's policy or to remember a decision. Prompts for sessions this gateway does not drive are passed straight on to the deployment's own answerers.

Audit frames (always on)

Independent of the settings above, the approval audit trail is relayed, so a stopped turn is never mistaken for a slow one:

framemeaning
approval_asked{ id, toolName, callId, reason } — the turn is stopped, waiting on a decision
approval_decided{ id, outcome }allowed-once / rejected / cancelled / unavailable
approval_policy{ policy, source }ask or never for this session

With approvals: host and nobody watching the deployment's UI, the fail-closed default applies and the outcome is unavailable. policy: never is the deterministic stance for unattended runs: every prompt is rejected without asking anyone.

Security model

Why can POST /key just hand out a key? It's a first-call bootstrap, not an open mint:

  • Only when no key exists at all (apiKeys empty and provisionedKey unset) does POST /key generate a 32-char random key.
  • The key is persisted before it is returned, into the settings scope as provisionedKey. So it keeps working across restarts, and the bootstrap closes permanently — every later call gets 403 key_already_provisioned. That is what "once" means here: once ever, not once per restart.
  • By default the gateway listens on loopback, so the only possible "first caller" is you, the deployer — equivalent to setting a password at first boot.
  • A deployment that configures apiKeys has the bootstrap closed from the start: configuring a key is having a key.
  • Don't trust the window? Close it: allowKeyProvision: false, keys only from apiKeys: [...].
  • Rotate with POST /admin/rotate-key (needs adminKey). It replaces provisionedKey only and leaves apiKeys alone — that list is the operator's, and the gateway has no business revoking it.
  • Keys are never logged. To check whether a key is set, read apiKeySet from GET /health.

A deployment with no settings provider cannot persist anything, so it falls back to an in-memory key (lost on restart), reports persisted: false, and logs a warning. Such deployments should use apiKeys directly.

Defense in depth (production checklist):

  1. allowKeyProvision: false + pre-provisioned apiKeys
  2. Keep the gateway loopback-bound; put a reverse proxy + TLS in front if exposed
  3. Separate adminKey from API keys
  4. Per-session agent contexts; session ids are cryptographically random
  5. Authorization: Bearer as the canonical header (X-API-Key kept as an alias)
  6. Constant-time key comparison (crypto.timingSafeEqual), CSPRNG key generation

Known gaps (public, see roadmap): no per-key rate limiting/quotas, no revocation list, no multi-key management UI, no audit. For hostile multi-tenant scenarios wait for v0.2+, or front the gateway yourself. Holding an API key can discover/read/adopt all sessions — a feature for single-owner setups, a risk otherwise; disable via allowDiscover/allowAdopt (per-key allowlists land in v0.2.0).

Workspace membership

API sessions join workspaces just like GUI sessions — sidebar shows them grouped, never "ungrouped". POST /sessions accepts workspace in three forms:

{ "workspace": "C:\\projects\\team-a" }                                // path string
{ "workspace": { "path": "C:\\projects\\team-a", "title": "Team A" } } // + title on create
{ "workspace": { "id": "ws-xxx" } }                                    // existing workspace id

Rules (deterministic, server-side):

  • Path resolves to an existing workspace → reused; otherwise auto-created (title defaults to the basename)
  • Unknown id → 400 with the current workspace list (id/title/path)
  • No workspaceworkspaceMode: auto (default — resolve-or-create for the session cwd / defaultWorkspacePath) or ungrouped
  • Both cwd and workspace given → workspace wins; session cwd is forced to the workspace canonical path (the durable membership invariant: header cwd == workspace path)
  • Path pointing at a missing directory → 400 (the gateway never creates directories)

Responses and history include workspace: { id, path, title }. Shared collaborative workspaces (multiple keys on one path) arrive in v0.2.0.

Session discovery & adoption (continue GUI sessions over the API)

# ① discover sessions
curl -s $BASE/sessions/discover -H "Authorization: Bearer $KEY"

# ② adopt one: live co-driving, or cold resume; returns the full history
curl -s -X POST $BASE/sessions/$SID/adopt -H "Authorization: Bearer $KEY"
# → { "mode": "live" | "resumed", "history": [...] }

# ③ keep chatting — identical to gateway-created sessions
curl -s -X POST $BASE/sessions/$SID/messages \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"content":"continue where we left off"}'
modeMeaningLifecycle
createdGateway-created sessionOwned by the gateway
liveCo-driving a GUI-open session: API messages appear in the GUI flow, turns queue from both sidesBorrowed only — plugin stop just untracks it
resumedCold-resume of an offline session (needs sessionPersistence)Owned by the gateway after resume

GET /sessions/:id/history works for any session (read-only, no adoption needed); /messages, /stream, /cancel require adoption first.

vs the official Python SDK

DeepSeek Harness also ships an official Python SDK (tutorial / SDK reference). The two are not the same thing and not substitutes:

Official Python SDKThis gateway
NatureEmbedded runtime: pip install deepseek-harness-sdk ships a platform wheel and drives a bundled dsh-jsonrpc-agent subprocess over JSON-RPC stdioA door into a running Harness: a host-composition plugin exposing REST + SSE
Model credentialsDeepSeek API keys (DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL)Gateway's own API keys (independent of model credentials)
SessionsPrivate JSONL under session_root, unrelated to any deployment or GUIThe deployment's shared session corpus: GUI-visible, workspace-grouped, adoptable
CapabilitiesMinimal default composition (local bash etc., no skills, no compaction; customizable via cordis)The deployment's default agent preset (tools/skills/sandbox policy)
PlatformsLinux x64/arm64, macOS 14+ arm64; no WindowsAny client language/platform, Windows PowerShell included
Isolationdanger-full-access; run in disposable environments/containersInherits the deployment sandbox and approval policy; asks and approvals can be relayed to the client and answered over HTTP
Best forOne-off isolated tasks from Python scripts without a long-running deploymentThird parties connecting to your running deployment, multi-language, unified auth/limits/audit, continuing GUI sessions

Choose the SDK for disposable Python tasks; choose this gateway for everything that needs a persistent, shared, cross-language door. Don't mix the two: DeepSeek sk-… keys don't open this gateway, and pip install deepseek-harness-sdk does not connect to it.

Extensibility (for other plugins)

The gateway publishes these events on the Cordis event bus; other host plugins subscribe with ctx.on(...) (listeners are fiber-owned and can never break the gateway):

  • gateway/session-created{ sessionId, mode: 'created' | 'live' | 'resumed', workspace, cwd }
  • gateway/session-released{ sessionId, mode, disposed }
  • gateway/message{ sessionId, messageId, text, usage } (on each committed assistant reply; usage is the step's token accounting or null)
  • gateway/turn-end{ sessionId, turn, reason, detail, usage, provider, model } (usage is the turn total summed over its steps, null when no step reported accounting)
  • gateway/question-asked{ sessionId, questionId, questions } and gateway/question-answered{ sessionId, questionId, answers }
  • gateway/approval-pending{ sessionId, decisionId, toolName, callId, reason } and gateway/approval-decided{ sessionId, decisionId, outcome }

Typical uses: audit persistence, external alerting, forwarding to IM/webhooks, custom rate-limit sidecars.

Development & testing

pnpm install
pnpm build        # tsc
pnpm smoke        # end-to-end smoke against a running gateway

Smoke env: DSH_AGW_BASE (default http://127.0.0.1:3080/api-gw/v1), DSH_AGW_KEY (optional — claims a key if absent), DSH_AGW_PROMPT. CI (.github/workflows/ci.yml) runs build + syntax checks, with an optional smoke job activated by repository variables.

Roadmap

Milestones ordered by "security first, then experience, then ecosystem"; each version ships independently.

VersionThemeContents
v0.1.0Baseline (current)REST + SSE, settings card, reasoning/text split, workspace membership, session adopt, interactive questions and approvals answerable over the API, cross-platform docs
v0.2.0Multi-tenant security ★Multi-key CRUD/revocation, per-key rate limiting (429 + Retry-After), workspace model: per-key isolated + shared collaborative workspaces (shared/isolated), per-key approval policy, audit (requests/sessions/token usage per key), session persistence (resume after restart)
v0.3.0Admin UIFull admin settings page (keys/limits/workspace bindings, session monitor, usage audit, soft switch) + typert @Remote config surface (the admin page's foundation) + per-key agent preset selection
v0.4.0Duplex streamingwebServer.registerUpgrade WebSocket full-duplex (send/stream/cancel on one connection); SSE stays as the lightweight option. Questions and approvals already work over SSE + POST; duplex buys lower round-trip latency and server-initiated withdrawal
v0.5.0Ecosystem & opsPython/Node HTTP thin clients (OpenAPI-generated — not the official embedded SDK, see above), deployment guide (reverse proxy + TLS, Docker Compose), metrics/telemetry export, OpenAPI generation in CI

Out of scope / deferred: horizontal multi-process scaling, built-in TLS termination (a reverse proxy's job), OAuth/OIDC (revisit after the key-based model settles).

License

MIT