Multi-Entry & Typert: One Kernel, Five Faces
Web, headless, ACP, SDK, and HTTP share one kernel.
Play first, then we talk. Left is the entry, right is the kernel. The five tabs are five faces—pick one and hit Play: see what skin that entry wears, what message it sends, then what events land in the kernel’s session log. Switch entries and replay; watch the event row on the right. That’s the whole lesson.
examples/headless-agent/cordis.yml, examples/jsonrpc-agent/cordis.yml, examples/acp-agent/cordis.yml, and docs/api-gateway.zh.md; shared kernel plugins are the intersection of the three configs.Earlier lessons said every DSH capability is a Cordis plugin; at process start a cordis.yml mounts them into a tree. That design pays off here: an “entry” is just a different cordis.yml. Under examples/ sit three ready ones—headless, JSON-RPC, ACP. Open them side by side and they’re mostly alike: DeepSeek adapter, bash executor, JSONL session persistence, compaction, filesystem tools—all three share those kernel plugins. Diffs cluster at the top lines: JSON-RPC adds sdk-jsonrpc-server; ACP adds an acp-demo protocol bridge and sandbox policy; headless hangs no server at all—the process itself is the entry.
The Web face wears a thicker skin, but it’s still plugins: host-webserver is a pure node:http carrier—docs say it isn’t part of the agent loop and knows no harness concepts (docs/subsystems/web-server.zh.md); frontend-static claims the fallback seat as an SPA server; client-modules uses tapIndex to inject the boot list window.__DSH_BOOT__ into index.html so the browser loads each plugin’s frontend modules. The HTTP API face is a chain: api-remotes for identity resolution, api-gateway for arg decode and method call, connection owning the RPC envelope on /api, then back to the same webserver (docs/api-gateway.zh.md).
The Python SDK shows how thin the “skin” can be. pip install deepseek-harness-sdk also installs a platform wheel with the single-file dsh-jsonrpc-agent; the SDK starts it as a subprocess, injects the default composition via DSH_CORDIS_CONFIG, then speaks JSON-RPC over stdio (python/sdk/README.zh.md). So Python SDK and the JSON-RPC entry are two outfits for the same face—Python only wraps the protocol as harness.run("…").
The outline’s boundary-condition question is answered in the config comments. Line 2 of the JSON-RPC example says stdout is reserved for JSON-RPC—no console logger or terminal UI; the ACP example likewise declares the whole tree hangs neither stdout logging nor HMR, because stdout carries ACP’s JSON-RPC (opening comments in both cordis.yml files). In one line: both protocols use stdout as the wire—mix in one log line and the peer parser drops. Logs go through ctx.logger elsewhere. That’s the iron rule for protocol entries.
entry = one cordis.ymlThe three example entries share the same kernel plugins; diffs are the top protocol-bridge lines. A new face ≈ one translation plugin plus one config.
stdout belongs to the protocolJSON-RPC and ACP entry configs ban hanging a console logger: stdout is the wire; one mixed-in log line and the peer fails to parse.
unmarked methods don’t existTypert only exports methods marked @Remote; unmarked ones never enter Client types and can’t be called via ctx.remote.
Open examples/jsonrpc-agent/cordis.yml: line 1’s comment says this is for unattended bundled-runtime deploy; line 2 is the iron rule verbatim—stdout reserved for JSON-RPC, no console logger or terminal UI. Further down, the protocol bridge sdk-jsonrpc-server is just an ordinary first item in the plugin list, and even its config arrives via env injection. That’s an entry’s whole kit: one config, a few top lines for its face, the rest the same shared kernel.
Source: examples/jsonrpc-agent/cordis.yml lines 1–7, verified on 2026-08-13.
Among the five faces, Web and HTTP API need cross-process calls into Host business methods—so you need an RPC layer. DSH skipped off-the-shelf frameworks and built Typert. Business authors do almost nothing but one decorator: mark a service method @Remote('create'); at build time Typert walks the TypeScript type graph and emits three things—a Zod schema for args, a call descriptor, and browser-side type declarations. No hand-written route tables, arg transforms, or client stubs; change one method signature, rebuild, and every client’s contract updates in sync (docs/subsystems/typert.zh.md, packages/typert/generator/README.zh.md).
Why won’t stock solutions work? Things that cross the wire include Cordis-specific concepts that generic schema generators have no words for. Three examples. First, Host and browser are two separate TypeScript Programs; the same Cordis Context name merges differently on each side—one schema can’t feed both. Second, a business method may take a live object like Agent, which can’t serialize over the wire; Typert’s lookup rewrites the agent param to wire field agentId, and the Gateway resolves the id back to a live object before calling. Third, client ctx.remote.goals is a live service that mounts and unmounts with plugins—when the last method is withdrawn, the whole namespace goes with it; that’s a lifecycle static OpenAPI can’t express (Typert Gateway Agent Note 2026-08-02).
One more discipline worth remembering: descriptors are local reflection—they never go on the wire. Host and client each generate matching descriptors at build time; requests carry only the endpoint and named args; cancel signals inject as an out-of-band carrier signal, never mixed into business args (the call-descriptor section of docs/subsystems/typert.zh.md). The generator is stubborn: unexpressible type projections error out—it never flattens and weakens the source type to paper over gaps. Same spirit as the last two lessons’ “refuse to parse”: if you can’t say it clearly, don’t do it.
Real Goal-service code. One decorator plus a thin adapter—the JSDoc line “exact live Agent resolved from the wire identity” is what lookup looks like on the business side:
/**
* Create one Goal through the remote boundary.
* @param agent - exact live Agent resolved from the wire identity.
* @param request - objective and optional round cap.
* @returns the created Goal identity.
*/
@Remote('create')
remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult {
const view = this.create(agent, request)
return { ref: { id: view.id, revision: view.revision } }
}
deepseek-harness-master repo; verified against packages/goal/goal/src/index.ts, verified on 2026-08-13. Code blocks keep the original source text.Grok Build
The two meet head-on on the ACP face. Grok Build has its own Rust implementation xai-acp-lib (crates/codegen/xai-acp-lib/): stdin line reader, bidirectional channel, gateway transceiver—also JSON-RPC over stdio, so the same “stdout belongs to the protocol” discipline applies. Editors talking to coding agents—ACP is becoming the de facto standard.
The difference is how many faces and how they grow: Grok Build centers desktop and CLI, with ACP as the editor interface; DSH flattens all five faces into config diffs, and the kernel knows nothing about entries. The in-site Grok series already dissected its architecture—worth reading side by side.
Claude Code
Opposite route: TUI-first. The terminal CLI is the core; headless is the same binary’s -p mode; the Agent SDK wraps another layer—multiple faces branch from one CLI. One process, one UI—no RPC layer, so nothing for Typert to solve.
DSH is Web-first; at launch it didn’t even ship a classic interactive terminal entry, and the loudest voice in the release thread was “where’s my CLI?” That’s a product call on entry trade-offs: first stand up an architecture that decouples kernel from face, then add whichever skin is missing. Neither route is right or wrong—cost structure differs: TUI-first adding a Web face needs a whole RPC layer; Web-first adding a CLI face is, in theory, one new cordis.yml plus a driver.
Design a sixth face
Suppose you add a chat-app bot entry to DSH: users @ the bot in a group, replies stream back into the group. List it the way this lesson does: what don’t you write? (Kernel plugins, session persistence, compaction, tools—copy the existing cordis.yml wholesale.) What must you write? (One protocol-bridge plugin that turns group messages into session prompts and session events into group replies.) One more detail: does this bridge have a stdout mutual-exclusion problem? If not, what’s the equivalent of its “wire discipline”? (Hint: group messages have rate limits and length limits—event streams need throttling and merging.)
cordis.yml plus a protocol-translation plugin—same operation, whichever face it enters, the session-log events land identical. Protocol entries keep the iron rule “stdout belongs to the protocol”; logs never mix onto the wire. Cross-wire calls go to in-house Typert: one @Remote decorator, the type graph emits schema, descriptor, and client types; live objects become wire ids via lookup; change one signature and every end stays in sync.