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.
  • Typert generation (see SDK: Typert) reads those declarations and emits InvocationDescriptors plus typed client stubs.
  • Connection provides the physical channel: the same HTTP + SSE bridge used by everything else, with a shared /api FetchHandler.
  • 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.

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 the Goal Remote contribution and the read-only plugin inventory (pluginInventory/list).
  • 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 gateway and the proxy (see API Proxy (apiproxy)) share the /api route: Connection passes the composite handler through its HTTP bridge, the handler dispatches claimed endpoints to the Gateway and unclaimed endpoints to the API Proxy. Model chat traffic therefore flows through the proxy path, while typed business RPC flows through the gateway.

Key source files

Repo-relative pathWhat it provides
packages/api/gateway/src/index.tsHost TypertGatewayService, ctx.typertGateway
packages/api/gateway/src/client/Client face: ClientRemote, ctx.remote
packages/api/gateway/src/types.tsWire types, TypertGatewayError
packages/api/remotes/src/remote-events.tsForwarded-event allowlist
packages/api/remotes/src/index.tscreateApiRemoteAgentResolver, identity policy
docs/api-gateway.mdThe design reference (bilingual)

Further reading