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.json → tsdown --env.DSH_BUILD_FACE host (Typert codegen runs here, only) → tsc -b tsconfig.client.json → tsdown --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.
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.”
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
- Source citations pulled directly from the repo as of commit
47f9438(master, Aug 13, 2026 — same commit cited throughout this series):packages/host/apiproxy/src/api/index.ts,packages/typert/registry/src/service.ts,packages/api/gateway/src/index.ts,packages/host/webserver/src/index.ts,packages/web/web/src/index.ts docs/subsystems/typert.md,docs/api-gateway.md,docs/subsystems/web-server.md,docs/subsystems/web.md- Agent Client Protocol — the external spec
packages/acpimplements - Part 1 through Part 12 of this series
Have thoughts on this?
I read every email. If something resonated, felt wrong, or made you think — I'd love to hear from you.
Comments