Remote API Gateway
One-liner: the Typert Remote gateway is the two-sided RPC endpoint of the Web UI: the host side
ctx.typertGatewayvalidates named arguments against the generatedInvocationDescriptor, resolves agent/session identities, and injects cancellation; the client side (@deepseek-ai/dsh-api-gateway/client) providesctx.remote($mount()/$on()/$dispatch()); plugin authors expose host capabilities remotely with the@Remote/@RemoteScopedecorators, 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) exposesctx.typertGateway @deepseek-ai/dsh-api-gateway/client(packages/api/gateway/src/client/index.ts) exposesctx.remote- both consume the same generated
InvocationDescriptorcontract; 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():
| Step | Source action |
|---|---|
| Resolve descriptor | resolveDescriptor: first strict definition (ctx.typert.local), else SRC fallback; withdrawing an observed strict definition errors instead of weakening validation |
| Validate named arguments | assertExactArguments: args must exactly match the descriptor's wire fields (both missing and extra throw arguments-invalid) |
| Resolve receiver | resolveReceiverContext: direct uses the ctx root; context resolves identity through a registered Host Context provider |
| Validate binding | validateBinding: the service must carry a consistent typertRemote binding, else binding-invalid |
| Invoke & validate result | Reflect.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:
| code | meaning | trigger |
|---|---|---|
arguments-invalid | arguments don't match the descriptor | missing / unexpected fields |
lookup-not-found / lookup-failed / lookup-unavailable | lookup resolution failed | identity unresolved / provider error / no key |
context-not-found / context-failed / context-unavailable | @RemoteScope receiver resolution failed | Host Context provider missing or unresolvable |
binding-invalid / service-unavailable / method-unavailable | binding or service missing | no typertRemote binding / no active Service / no callable method |
input-invalid / result-invalid | boundary validation failed | wire field or result fails the codec |
ambiguous-endpoint / signature-invalid / definition-unavailable | SRC derivation failed | multiple 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
}
@Remotemarks 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 forkey— each call targets the scope's context of a specific session/agent- When another base class owns inheritance and you cannot extend
TypertRemoteService, usebindTypertRemote(service, serviceKey, options)explicitly - In SRC derivation
@RemoteScopemaps 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:
| API | purpose |
|---|---|
$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 calls | validate positional inputs → construct the exact named args → ctx.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
getterclosure 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:
| Policy | Behavior |
|---|---|
| Reuse live Agents | fencedLiveAgent: a ctx.agents.get(sessionId) hit is used directly (unless fenced) |
| Resume ordinary cold sessions | when not live, inspect the cold session via persistence.inspect, then ctx.agents.resume |
| Deduplicate concurrent resumes | a resumes Map shares one resume Promise per identity, deleting it on completion |
| subagent ownership fence | hasApiRemoteSubagentOwner: 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
./typesexports (dsh-agent-presets/dsh-commands/dsh-credentials/dsh-llm/dsh-settings), so "forwarded verbatim" holds by construction - Events reach
$onexactly 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
- Web UI Architecture: the dual-process context of the connection layer and apiproxy
- Plugin Inventory: the read-only projection behind the
pluginInventory/listRemote - Event System: forwarded events come from the host event stream
- Plugin Anatomy: the dual-face / Remote boundary