Skip to content

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).

PackageRole
@deepseek-ai/dsh-api-gatewayTwo-sided Typert RPC endpoint: Host ctx.typertGateway, Client ctx.remote
@deepseek-ai/dsh-api-remotesApplication-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 /api prefix with a 300 MiB body cap (DEFAULT_MAX_REQUEST_BODY_BYTES) and application/json enforcement (415).
  • The gateway is deliberately two-sided: the Host entry registers TypertGatewayService (ctx.typertGateway), the Client entry provides ClientRemote (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 TypertLookupMap and a default resolution provider via ctx.typert.lookups; an Agent parameter named agent becomes an agentId wire field, resolved back to a live Host object before invocation. Host composition can override policy with effect-scoped ctx.typert.lookups.configure().
  • @RemoteScope resolves an identity to a scoped Context via ctx.typert.contexts, then obtains the service from that Context — used when the method depends on per-agent composition.
  • Cancellation. A Remote method declares signal: AbortSignal as 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; TypertGatewayError distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may carry an existing RPC error in TypertLookupFailure to 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 Typert agent and session lookups — so migrated and unmigrated methods share one policy implementation.
  • Client entry imports generated /remote artifacts as runtime values, mounts each contribution through ctx.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, and workspace — 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' /remote declarations (see packages/api/remotes/README.md).

A worked example ​

From docs/api-gateway.md — a business service exposing goal creation:

ts
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 pathWhat it provides
packages/api/gateway/src/index.tsHost TypertGatewayService, ctx.typertGateway, the stream mux server
packages/api/gateway/src/stream-protocol.tsREMOTE_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.tsWire types, TypertGatewayError
packages/api/remotes/src/remote-events.tsForwarded-event allowlist
packages/api/remotes/src/client/index.tsThe 12-name Remote assembly ($mount loop)
packages/api/remotes/src/index.tscreateApiRemoteAgentResolver, identity policy
docs/api-gateway.mdThe design reference (bilingual)

Further reading ​