The API gateway is the typed remote-call bridge between the Host and the browser Client. It is built on the Typert type-generation system: business packages declare unary RPC methods with decorators, the build generates matching Host and Client contracts, and every call travels over the shared Connection RPC /api route. The authoritative design document is docs/api-gateway.md; the implementation lives in packages/api/gateway (the gateway) and packages/api/remotes (the application-level BFF facade).
| Package | Role |
|---|---|
@deepseek-ai/dsh-api-gateway | Two-sided Typert RPC endpoint: Host ctx.typertGateway, Client ctx.remote |
@deepseek-ai/dsh-api-remotes | Application-level BFF: Agent/Session identity policy, forwarded-event allowlist, Client mount facade |
@deepseek-ai/dsh-typert-protocol | @Remote / @RemoteScope decorators, TypertRemoteService, invocation descriptors |
Why a gateway at all
The web client runs in the browser; the agent loop, sandbox, and storage run in the Node host. Rather than exposing a hand-rolled REST surface per feature, dsh generates one:
- Business services mark the methods they expose with
@Remote('name')(root-context service) or@RemoteScope('name')(per-agent scoped context service). Unmarked methods never reach the client — neither in generated types nor at runtime. Stream endpoints use@Remote({ mode: 'stream' })and are served over the gateway-owned WebSocket mux rather than unary HTTP. - Typert generation (see SDK: Typert) reads those declarations and emits
InvocationDescriptors plus typed client stubs. - Connection provides the physical unary channel: the shared
/apiprefix with a 300 MiB body cap (DEFAULT_MAX_REQUEST_BODY_BYTES) andapplication/jsonenforcement (415). - The gateway is deliberately two-sided: the Host entry registers
TypertGatewayService(ctx.typertGateway), the Client entry providesClientRemote(ctx.remote), and both consume the same generated descriptor contract.
Host side: TypertGatewayService
ctx.typertGateway.invoke() is the single entry: it resolves the current descriptor and Cordis service per call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates the result.
Key mechanics, from packages/api/gateway/src and docs/api-gateway.md:
- Strict vs SRC mode. Strict mode reads generated invocation descriptors from
ctx.typert.local. SRC mode is a development fallback for endpoints that never had a strict definition: it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. - Lookups. Complex Host objects cannot cross the wire. A business package registers its identity mapping through
TypertLookupMapand a default resolution provider viactx.typert.lookups; anAgentparameter namedagentbecomes anagentIdwire field, resolved back to a live Host object before invocation. Host composition can override policy with effect-scopedctx.typert.lookups.configure(). @RemoteScoperesolves an identity to a scoped Context viactx.typert.contexts, then obtains the service from that Context — used when the method depends on per-agent composition.- Cancellation. A Remote method declares
signal: AbortSignalas its final Host parameter. It is descriptor metadata, not a wire argument: Connection supplies the signal to the gateway, which injects it after decoded business parameters. - Errors. Direct
invoke()calls preserve business errors;TypertGatewayErrordistinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may carry an existing RPC error inTypertLookupFailureto preserve its original error code (used for policy rejections like cold-resume failures).
Client side: ClientRemote
ctx.remote.$mount() validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced remote.<namespace> child service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
Each call validates positional inputs, constructs the descriptor's exact named args, and sends it through ctx.connection.rpc.call('/api', endpoint, ...). Generated cancellation-aware methods accept a final optional AbortSignal, combined with the contribution mount lifetime.
Stream-mode endpoints surface as typed stream carriers instead of unary calls: RemoteStream<Item> (incremental), RemoteSnapshotStream<Snapshot, Delta> (complete baseline then replacement frames), and RemoteJournalStream (page/entry/cursor journal reads) — all multiplexed over one shared WebSocket at /api/remote.mux (REMOTE_STREAM_MUX_PATH, packages/api/gateway/src/stream-protocol.ts), kept alive by gateway WebSocket Ping heartbeat frames. The gateway's unary dispatch and the stream mux therefore share one contract: a decorated @Remote({ mode: 'stream' }) method is served live, everything else unary.
ctx.remote.$on() subscribes to one forwarded Host event; its legal keys are exactly the Host assembly's forwarding selection (see API_REMOTE_FORWARDED_EVENTS in packages/api/remotes/src/remote-events.ts), and the listener type is the owning package's own Cordis Events declaration — so no second signature can drift from it. Subscriptions belong to the calling fiber and disappear with it.
dsh-api-remotes: the application BFF
packages/api/remotes is the two-sided facade selected by this application:
- Host entry owns Agent/Session identity policy.
createApiRemoteAgentResolver()reuses live agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for Typertagentandsessionlookups — so migrated and unmigrated methods share one policy implementation. - Client entry imports generated
/remoteartifacts as runtime values, mounts each contribution throughctx.remote.$mount(), and re-exports declaration merges type-only. At this revision the Client assembly mounts 12 namespaces in one loop (packages/api/remotes/src/client/index.ts:146-150):agentPresets,commands,settingsController,goals,llm,dynamic,pluginInventory,messageFeedback,sessionReferences,subagents,session, andworkspace— the four controller/domain additions (agentPresets,settingsController,llm,sessionReferences) alongside the older five and the session/workspace controllers' namespaces. - The package is the only deliberate split-face package in the repo: its Host entry must participate in the Host Typert graph while its Client entry cannot compile until Host tsdown has generated the business packages'
/remotedeclarations (seepackages/api/remotes/README.md).
A worked example
From docs/api-gateway.md — a business service exposing goal creation:
import type { Agent } from '@deepseek-ai/dsh-agent'
import { TypertRemoteService, Remote, RemoteScope } from '@deepseek-ai/dsh-typert-protocol'
export interface CreateGoalRequest { objective: string }
export interface CreateGoalResult { accepted: boolean }
export class GoalService extends TypertRemoteService {
constructor(ctx: Context) { super(ctx, 'goals') }
@Remote('create')
createForClient(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): CreateGoalResult {
// agent is resolved from agentId by the gateway before this runs
}
@RemoteScope('agent', 'current')
currentForClient(): CreateGoalResult {
// the current-agent variant; the full example also shows a private create() helper shared by both
}
}The wire never sees Agent; it sees agentId, and the gateway resolves it against the Host's live agent registry under the identity policy configured by api-remotes.
Relationship to the API proxy
The old packages/host/apiproxy that used to own the unclaimed half of /api is deleted. Today the shared /api route has exactly one owner set: the Connection node half dispatches into a fetch handler that selects an exact registered fetch route and returns 404 for anything else — there is no fall-through. Typed business RPC rides the gateway; live streams ride the gateway's /api/remote.mux WebSocket. Model traffic enters through the session-controller prompt path (session.prompt resumes the Session and admits the prompt; the agent loop then calls ctx.llm.stream host-side), while the client owns llm.discoverModels/llm.providers for pickers only. The client now reaches all of this through the controller namespaces mounted by dsh-api-remotes (see API Layer).
Key source files
| Repo-relative path | What it provides |
|---|---|
packages/api/gateway/src/index.ts | Host TypertGatewayService, ctx.typertGateway, the stream mux server |
packages/api/gateway/src/stream-protocol.ts | REMOTE_STREAM_MUX_PATH = '/api/remote.mux', stream wire frames, heartbeat |
packages/api/gateway/src/client/ | Client face: ClientRemote (ctx.remote), RemoteStream/RemoteSnapshotStream/RemoteJournalStream |
packages/api/gateway/src/types.ts | Wire types, TypertGatewayError |
packages/api/remotes/src/remote-events.ts | Forwarded-event allowlist |
packages/api/remotes/src/client/index.ts | The 12-name Remote assembly ($mount loop) |
packages/api/remotes/src/index.ts | createApiRemoteAgentResolver, identity policy |
docs/api-gateway.md | The design reference (bilingual) |
Further reading
- API Layer (gateway, remotes & controllers) — the
/apiroute, session/settings/workspace controllers, and browser-session auth - Typert: The Type Generator — how descriptors and client stubs are generated
- Host Platform — the webserver that mounts these services
- The LLM Layer — model traffic and providers
- Repo:
docs/api-gateway.md,packages/api/gateway/README.md,packages/api/remotes/README.md