SherlockLiu Logo SherlockLiu
Back to all posts
Engineering

Three Surfaces, One Spine: Web, Typert RPC, and SDK/ACP (Part 13)

SL
Aug 14, 2026 7 min read
Three Surfaces, One Spine: Web, Typert RPC, and SDK/ACP (Part 13)

Series: Inside the DeepSeek Harness — Part 13 of 16


Everything so far in this series has lived inside one process. This post is about how the outside world reaches it — a browser tab, a typed remote-procedure client, an IDE speaking the Agent Client Protocol, an external automation script. Three genuinely different surfaces, and — true to the pattern this whole series keeps finding — one spine underneath all of them.


Two compiler faces, and the one package that has to split

dsh compiles as two separate TypeScript programs: tsconfig.host.json (Node — core, packages, boot, bundles) and tsconfig.client.json (browser — the client package, the web app). They’re never merged, and the reason is concrete, not stylistic: both Host and Client declaration-merge Cordis’s Context interface under the same keys, but with different services behind them. A single ts.Program that saw both merges at once would report a real collision. Exactly one package, packages/api/remotes, is the documented exception that splits its own project across both faces, because its Host half has to join the Host Typert graph while its Client half imports /remote declarations that only exist after the Host build has already generated them.

That ordering is real and sequential, not parallel: tsc -b tsconfig.host.jsontsdown --env.DSH_BUILD_FACE host (Typert codegen runs here, only) → tsc -b tsconfig.client.jsontsdown --env.DSH_BUILD_FACE client → the web app build. The client face literally cannot compile correctly until the host face has already finished generating the types it depends on.


Typert: one decorator, three generated artifacts

Here’s the repo’s own canonical teaching example for this pattern (from docs/api-gateway.md, not a literal .ts file — the real GoalService exists but its actual methods are named and shaped differently; this is the clearest way the docs themselves demonstrate what “typed RPC” means here):

export class GoalService extends TypertRemoteService {
  constructor(ctx: Context) { super(ctx, 'goals') }

  @Remote('create')
  createForClient(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): CreateGoalResult { ... }

  @RemoteScope('agent', 'current')
  currentForClient(): CreateGoalResult { ... }
}

@Remote calls a root-Context Cordis service directly. Complex Host-only objects — like a live Agent — can’t cross the wire as-is, so they’re declared through a TypertLookupMap: the agent parameter above becomes an agentId field on the wire, resolved back to the real object server-side. @RemoteScope(key) is for a different shape of method — resolve an identity first, get a scoped Context (Part 3’s primitive, reused here for RPC dispatch), then call a service on that scoped context. And cancellation is handled as a reserved, always-final signal: AbortSignal Host parameter — recorded in the method’s descriptor, injected out-of-band by the gateway, and it never appears in the wire arguments at all.

@Remote('create') one decorator, source typert.host.* + typert.remote-client.* ctx.remote.goals.create(...) fully typed on the client .d.ts.map lets an editor jump from the call site straight to the real method.

Figure: everything else the client sees for this method — types, argument shape, cancellation — is generated, not hand-maintained.

One dev-mode wrinkle worth knowing: when the Host runs straight from source (node --import tsx/esm, no build step), the TS compiler plugin that normally does this codegen never runs. The decorators still record method name and invocation mode into a runtime WeakMap at call time, so the gateway can construct a weaker, ad-hoc descriptor without a full ts.Program. This only ever solves dispatch for a Host running from source, though — the client never discovers decorators at runtime; it always mounts from whatever was last generated into lib/typert.remote-client.*. Strict, pre-built codecs only, client-side, always.


The RPC envelope: HTTP status means transport, not outcome

The real layer chain, stated in the docs verbatim: remotes → gateway → connection → webserver. The wire protocol is a four-quadrant envelope — ClientRequest | ServerResponse | ServerRequest | ClientResponse, with branded request IDs — and one rule shapes everything built on top of it: HTTP status expresses only carrier failures. A business-level error — a goal that can’t be created, a session that doesn’t exist — rides back as an ordinary 200 response with result.ok: false. The HTTP layer only ever reports “did the request itself get through,” never “did the thing you asked for succeed.”

business logic fails e.g. goal already complete HTTP 200 result: { ok: false, ... } A non-200 status means the carrier broke — the request never made it, or the server never ran it.

Figure: exactly the same "orthogonal outcomes" defensive pattern from Part 12, applied to transport vs. business-logic failure.

A trust fence (api-request-trust.ts) rejects DNS-rebinding and cross-site requests before they reach anything else; privileged methods pin to loopback specifically. WebServer itself resolves routes in a fixed order — an exact-match table first, then longest-matching prefix, then exactly one owned fallback seat (a second registration attempt throws) — with the shipped fallback owner serving the SPA’s index.html for any unmatched GET/HEAD path. Registration order carries zero request-facing meaning by design; routes are required to be disjoint, so which route wins is never a question of who registered first.

One more scoping decision worth knowing: host config accepts only 127.0.0.1 or 0.0.0.0 — no TLS, no built-in auth, no origin policy at this layer. Binding 0.0.0.0 is a deliberate, explicitly documented network-exposure choice a deployer makes, not a silent default anyone falls into by accident.


SDK and ACP: two different reasons to leave the process

packages/sdk speaks newline-delimited JSON-RPC 2.0 over stdio for external automation — a caller initializes, calls session/prompt, and gets back a messageId plus a stream of notifications until the whole agent goes idle. packages/acp speaks the Agent Client Protocol over the same stdio transport, but for a different purpose entirely: interoperability with other agent-facing tools, an IDE in particular. ACP explicitly excludes navigation, replay, modes, elicitation, and tool presentation — it’s automation-only, by design, not a stripped-down UI protocol. And Remote itself, worth being precise about, only ever handles unary method calls — one request, one result. Session event streams, pagination, and incremental data need a genuinely separate protocol; the architecture is explicit that they must never be dressed up as ordinary Remote methods just because it would be convenient.

The web capability seam — ctx.web, splitting search and fetch behind one service — is a clean bonus example of Part 5’s pattern applied somewhere you might not expect it. Provider selection is fully deterministic, never HMR-order-dependent: a configured id that’s missing throws WEB_PROVIDER_CONFIGURED_MISSING; exactly one usable provider with no config auto-selects; two or more with no config throws WEB_PROVIDER_AMBIGUOUS — explicitly not first-registered-wins. And a non-2xx fetch response is treated as a result, not an error — HTTP status is resource state the caller gets to see, not a seam failure the caller has to catch.


Coming up in this series

(All 16 parts are live today — no daily drip.)

Part Title What it covers
1 DeepSeek Harness: Inside the Open-Source Claude Code Rival The launch, the comparison, no privileged core, the four Cordis primitives
2 Composing an App From YAML, Not Code Profiles, bundles, five patch layers, boot, live HMR, --dump-config
3 Scope: Why a Live Agent Is the Key of Its Own Registration Shadowing, restriction, lineage vs. scope
4 Tool Execution in DeepSeek Harness: Guards and Approval Pre-execute, monotonic guards, post-execute, approval
16 DeepSeek Harness: Agent Presets as Data, Not Code Agent presets, PTC/Code Mode, the plugin ecosystem, compared to Claude Code’s agent types
5 Capability Seams: Making Bash Swappable for Sandboxed Bash The 3-role pattern end-to-end, across ~85 real seams
6 The Session Log: DeepSeek Harness’s Enforced Invariant The real invariant-checking code, surface projection
7 Persistence and Compaction: Crash-Safe by Construction JSONL/SQLite, torn-tail repair, the compaction lock bracket
8 Waterfalls: The One Event Pattern That Runs Everything Five dispatch modes, durable vs. live events, retry
9 The Agent Loop: Turns, Steps, and a Real Cancellation Bug The phase state machine, the inbox, a dated bug fix
10 The LLM Layer: One Message Format, Every Surface Message vocabulary, streaming, retry-via-log-replay
11 Subagents and Workflows: Composing Agents From Agents Provider kinds, continuable children, Ralph rounds
12 Defense in Depth: Sandboxing and Four Real Incidents bwrap/Landlock/Seatbelt/ACL, real production postmortems
13 Three Surfaces, One Spine: Web, Typert RPC, and SDK/ACP (this post) Typert codegen, the four-quadrant RPC envelope
14 Engineering Rigor: DeepSeek Harness’s Verification Gate The 100% coverage gate, real engineering-culture quotes
15 What a Second Production Harness Teaches dsh vs. the Agent Harness series, side by side

Part 16 shipped after this series’ initial 15 posts, once Agent Presets and Code Mode landed — it reads best right after Part 4, which is why its row sits there instead of at the end.

Next: Part 14 is about what actually holds ~150 packages to the standard this series keeps citing — the real coverage gates, the real test philosophy, and some genuinely quotable engineering-culture lines from the repo’s own contributor guide.


References

Comments