Skip to main content
PathDocs

Remote API Gateway

One-liner: the Typert Remote gateway is the two-sided RPC endpoint of the Web UI: the host side ctx.typertGateway validates named arguments against the generated InvocationDescriptor, resolves agent/session identities, and injects cancellation; the client side (@deepseek-ai/dsh-api-gateway/client) provides ctx.remote ($mount()/$on()/$dispatch()); plugin authors expose host capabilities remotely with the @Remote/@RemoteScope decorators, while api-remotes packages agent identity policy and the forwarded-event allowlist. It evolves from the legacy apiproxy, sharing the connection layer.

1. Two generations: from apiproxy to the Typert Gateway

The packages/api/gateway package is the two-sided Typert RPC endpoint for both Host and Client Cordis environments:

  • the Host entry (packages/api/gateway/src/index.ts) exposes ctx.typertGateway
  • @deepseek-ai/dsh-api-gateway/client (packages/api/gateway/src/client/index.ts) exposes ctx.remote
  • both consume the same generated InvocationDescriptor contract; transport, request correlation, trust, and response envelopes belong to Connection

Relationship to apiproxy: packages/host/apiproxy (package @deepseek-ai/dsh-host-apiproxy) is the legacy gateway shared by every client — it consumes ctx.apiProxy, config {nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?}, mounted as Web composition id api-gateway (see Web UI Architecture). The new Gateway registers a trusted-host interceptor on Connection's shared /api FetchHandler, dispatching claimed endpoints to the Gateway and unclaimed endpoints to API Proxy — so migrated and unmigrated methods coexist, and api-remotes' resolver is shared by legacy methods and Typert lookups (below).

2. Host service:ctx.typertGateway.invoke()

TypertGatewayService (packages/api/gateway/src/index.ts) does five things on every invoke():

StepSource action
Resolve descriptorresolveDescriptor: first strict definition (ctx.typert.local), else SRC fallback; withdrawing an observed strict definition errors instead of weakening validation
Validate named argumentsassertExactArguments: args must exactly match the descriptor's wire fields (both missing and extra throw arguments-invalid)
Resolve receiverresolveReceiverContext: direct uses the ctx root; context resolves identity through a registered Host Context provider
Validate bindingvalidateBinding: the service must carry a consistent typertRemote binding, else binding-invalid
Invoke & validate resultReflect.apply the method, decode the result through the codec boundary
// packages/api/gateway/src/index.ts —— cancellation is descriptor metadata, not a wire argument
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)

A cancellation-aware Remote method declares signal: AbortSignal as its final Host parameter — Connection supplies it to the Gateway, which injects it after the decoded business parameters, not from the wire.

Error categories

TypertGatewayError carries a stable machine-readable code:

codemeaningtrigger
arguments-invalidarguments don't match the descriptormissing / unexpected fields
lookup-not-found / lookup-failed / lookup-unavailablelookup resolution failedidentity unresolved / provider error / no key
context-not-found / context-failed / context-unavailable@RemoteScope receiver resolution failedHost Context provider missing or unresolvable
binding-invalid / service-unavailable / method-unavailablebinding or service missingno typertRemote binding / no active Service / no callable method
input-invalid / result-invalidboundary validation failedwire field or result fails the codec
ambiguous-endpoint / signature-invalid / definition-unavailableSRC derivation failedmultiple Services export the endpoint / invalid SRC signature / withdrawn definition

A resolver may use TypertLookupFailure to carry an existing RPC error, so policy rejections such as cold-resume failures or ownership fences keep their original error code (not flattened to internal). Direct invoke() calls preserve business errors; through the RPC adapter, ordinary dispatch failures and business exceptions map to internal, while TypertLookupFailure is returned unchanged.

SRC mode

SRC (a development fallback for endpoints that never had a strict definition) parses simple parameter names from the JavaScript signature (unique identifier parameters only — no destructuring, defaults, or rest), and accepts only JSON-safe values for non-lookup parameters, never inferring optional fields. The SRC cancellation parameter must be final.

3. Plugin authors:@Remote / @RemoteScope decorators

The decorators live in @deepseek-ai/dsh-typert-protocol (packages/typert/protocol/src/index.ts). Business services extend TypertRemoteService and mark methods with the decorators:

// packages/typert/protocol/src/index.ts —— the two markers
export function Remote(exportName?: string): RemoteMethodDecorator // direct Remote
export function RemoteScope(
key: Extract<keyof TypertContextMap, string>, exportName?: string,
): RemoteMethodDecorator // receiver resolved via Scope

export abstract class TypertRemoteService extends Service {
readonly typertRemote: TypertGatewayBinding<this> // binds the service key and wire namespace
}
  • @Remote marks a public instance method as a direct Remote call (endpoint = <namespace>/<method>)
  • @RemoteScope(key) has the method's receiver resolved by the Host Context provider for key — each call targets the scope's context of a specific session/agent
  • When another base class owns inheritance and you cannot extend TypertRemoteService, use bindTypertRemote(service, serviceKey, options) explicitly
  • In SRC derivation @RemoteScope maps to a context identity field on the wire (invocation.kind === 'context')

4. Client service:ctx.remote

ClientRemoteService (packages/api/gateway/src/client/index.ts) mounts generated Host-for-Client contributions as callable methods:

APIpurpose
$mount(contribution)validate and register a generated contribution, installing direct and scoped methods for the calling fiber; each namespace is a traced remote.<namespace> child Service, unloading after its last method is withdrawn
$on(event, listener)subscribe to one forwarded Host event; the legal keys are exactly the Host assembly's forwarding selection; the listener belongs to the calling fiber and disappears with it
$dispatch(event, args)the carrier-owned other half: the Client half holding the Host frame sink hands each decoded frame over; an event nobody subscribes to is dropped
method callsvalidate positional inputs → construct the exact named argsctx.connection.rpc.call('/api', endpoint, {args})
// packages/api/gateway/src/client/index.ts —— cancel via mount lifetime combined with the caller signal
const signal = callerSignal === undefined
? token.abort.signal
: AbortSignal.any([token.abort.signal, callerSignal])
  • No JavaScript Proxy: method lookup and invocation use ordinary objects and functions (a getter closure binds the calling fiber's ctx)
  • Withdrawal semantics: withdrawing a contribution removes its descriptors and methods, aborts in-flight calls; method handles still held externally reject as withdrawn
  • Each subscription delivers in registration order; a throwing listener is isolated (logged), never affecting the frame pump for the remaining listeners
  • The Client mounts only strict-generated contributions; SRC markers have no Client codec or type projection
  • Only unary methods are dispatched; incremental session data uses a separate named-stream protocol over the same Connection

5. api-remotes: application-side BFF and identity policy

packages/api/remotes (package @deepseek-ai/dsh-api-remotes) is the two-sided BFF for Host Remote capabilities selected by this application: the Host entry owns Agent/Session identity policy; the Client entry imports generated /remote artifacts as runtime values, $mount()s each contribution, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.

createApiRemoteAgentResolver: live reuse / cold resume / ownership fence

createApiRemoteAgentResolver() in packages/api/remotes/src/agent-lookup.ts does four things:

PolicyBehavior
Reuse live AgentsfencedLiveAgent: a ctx.agents.get(sessionId) hit is used directly (unless fenced)
Resume ordinary cold sessionswhen not live, inspect the cold session via persistence.inspect, then ctx.agents.resume
Deduplicate concurrent resumesa resumes Map shares one resume Promise per identity, deleting it on completion
subagent ownership fencehasApiRemoteSubagentOwner: a session whose origin is subagent or hangs under a parent subagent rejects generic routing
// packages/api/remotes/src/agent-lookup.ts —— the fence check
export function hasApiRemoteSubagentOwner(ctx, session, agent): boolean {
if (session.header.origin === 'subagent') return true
const parentId = session.header.parentSession
if (parentId === undefined || agent === undefined) return false
const parent = ctx.agents.get(parentId)
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
}

A fenced identity returns the original agent-busy RPC shape (apiRemoteSubagentOwnershipError), telling callers "this is a subagent session; use subagent delivery". The resolver configures both the Typert agent and session lookups, plus contexts.configureHost('agent', ...), so migrated and unmigrated methods share one identity-policy implementation. options.setup assembles the Agent-scope composition on cold resume keyed by the resumed session itself (an agent preset fixes the tools its history was produced under).

Forwarded-event allowlist:API_REMOTE_FORWARDED_EVENTS

packages/api/remotes/src/remote-events.ts is the single control point — it determines both the Host forwarding loop and the legal key set of ctx.remote.$on:

// packages/api/remotes/src/remote-events.ts —— forwarded verbatim: no projection, no redaction, no renaming
export const API_REMOTE_FORWARDED_EVENTS = [
'agent-preset/selected',
'commands/change',
'credentials/updated',
'llm/adapters-updated',
'settings/document-updated',
] as const
  • Forwarding one more event is one entry in the array; the type projection, the consumer key face, and the Host forwarding loop all derive from it
  • The Host face additionally asserts the list against TypertForwardableEvent: a name that is not a declared event, one that binds an AgentScope, and one whose shape is not one-way are all rejected at compile time
  • The listener signature comes from each owner package's client-safe ./types exports (dsh-agent-presets/dsh-commands/dsh-credentials/dsh-llm/dsh-settings), so "forwarded verbatim" holds by construction
  • Events reach $on exactly as the Host emitted them: no payload projection/redaction, no Scope-bound subscription, no replay after a reconnect

Build boundary

api-remotes is the repository's one deliberate split across the Host/Client faces: its root tsconfig.json is only a solution referencing tsconfig.host.json + tsconfig.client.json. src/remote-events.ts and src/types.ts are listed in BOTH faces' files, so the allowlist is one declaration; tsconfig.base.json maps @deepseek-ai/dsh-api-remotes/types to the source plane.

6. The Client assembly: which contributions are mounted

packages/api/remotes/src/client/index.ts explicitly imports and mounts four remote contributions:

commandsRemote · goalsRemote · pluginInventoryRemote · messageFeedbackRemote

The plugin-inventory contribution is provided by @deepseek-ai/dsh-host-plugin-inventory (see Plugin Inventory). The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime.

Verify

# confirm the gateway + remotes rows in the Web composition tree
dsh web --dump-config | grep -iE "api-gateway|api-remotes|apiproxy|plugin-inventory"

# see the port the host process listens on
lsof -nP -iTCP:3080 -sTCP:LISTEN

# in the browser Settings "Plugins" tab you can see the plugin inventory (via pluginInventory/list)

Next steps