CodeDocs Vault

Design patterns & decisions

1. Patterns used

Adapter pattern — agent CLIs

apps/daemon/src/agents.ts defines AGENT_DEFS as a flat array of objects, each conforming to a duck-typed interface:

{
  id, name, bin, fallbackModels, listModels?, reasoningOptions?,
  buildArgs(prompt, imagePaths, extraAllowedDirs, options, runtimeContext),
  streamFormat: 'claude-stream-json' | 'json-event-stream' | 'acp-json-rpc' |
                'pi-rpc' | 'copilot-stream-json' | 'plain',
}

12 implementations side-by-side in one file (~750 lines). New agents register by appending to the array. The daemon's getAgentDef(id) and resolveAgentBin(id) decouple lookup from spawn. Why one big file: the agent definitions are short and the family resemblance is the value — diffing two adapters in your editor is the documentation.

Strategy pattern — stream parsers

Five parsers (claude-stream.ts, copilot-stream.ts, acp.ts, pi-rpc.ts, json-event-stream.ts) implement the same downstream contract: typed events emitted to the SSE channel and persisted to messages.events_json. The daemon dispatches by def.streamFormat. Each parser is in a separate file because they share almost nothing structurally — Claude's JSON-Lines is line-delimited; ACP is JSON-RPC; Pi has the extension-UI auto-reply quirk.

Composer pattern — system prompt

composeSystemPrompt() (prompts/system.ts:109-191) is a pure function: input = { skillBody, designSystemBody, craftBody, metadata, template, ... }, output = a string. The composition order is hardcoded, not configurable. This keeps the prompt stack reviewable — every layer's precedence is one switch statement away.

Pluggable filesystem indexers

skills.ts, design-systems.ts, and craft.ts are three near-identical scanners over different folders. Each parses YAML frontmatter (skills, design systems) or plain markdown (craft) and returns a typed summary. They are not watched in MVP — every GET /api/skills re-scans the directory. This is fine for ~50 entries; a future chokidar watch is hinted at in architecture.md:329.

Factory + service registry — chat runs

apps/daemon/src/runs.ts exposes createChatRunService() which returns { create, start, stream, emit, fail, finish, isTerminal, ... }. Runs are tracked by runId in an in-memory Map. SSE streams are attached via runs.stream(run, req, res), and any subsequent runs.emit(run, eventType, payload) lands as event: <eventType>\ndata: <json>\n\n to all attached responses. Restarting the daemon drops in-flight runs but persisted events_json lets the browser replay.

Sidecar + stamp pattern — process orchestration

This is the most idiosyncratic pattern in the repo. Every long-running process gets a 5-field stamp serialized as CLI args:

--od-stamp-app=daemon
--od-stamp-mode=dev
--od-stamp-namespace=default
--od-stamp-ipc=/tmp/open-design/ipc/default/daemon.sock
--od-stamp-source=tools-dev

packages/platform writes them; packages/sidecar consumes them; packages/sidecar-proto defines the schema and validation. Why:

Capability-driven UI

apps/daemon/src/agents.ts probes each CLI's --help for optional flags (--include-partial-messages, --add-dir, etc.) and stores the result in agentCapabilities Map. def.buildArgs() consults the map before passing a flag — older Claude Code releases that pre-date a flag still work. The same pattern surfaces on the web side: features like comment mode are gated on agent.capabilities.surgicalEdit per docs/agent-adapters.md:198-207.

Anti-corruption layer — packages/contracts

The contracts package is pure TypeScript with an explicit no-import rule (no Next, Express, fs, browser, sqlite, sidecar deps). Web and daemon both import it. Whenever the wire format diverges, contracts gets updated first. This is the single rule that prevents web/daemon drift in a polyglot Express + Next setup.

Convention-based skill protocol

A skill is a directory; SKILL.md is required; od: frontmatter is optional. Defaults sniff for HTML/JSX, infer mode from name keywords, infer design-system requirement from body text (docs/skills-protocol.md:117-123). Zero-config compatibility with existing Claude Code skills is an explicit goal — op7418/guizang-ppt-skill runs unmodified.

Contract-as-prompt

prompts/deck-framework.ts and prompts/media-contract.ts aren't "examples" or "guidelines" — they are contracts the agent must obey verbatim. The deck framework is a paste-as-is HTML block; the media contract specifies an exact CLI signature. This sidesteps "creative drift" by removing creativity from the load-bearing surface.

Linter feedback loop

POST /api/artifacts/save runs lint-artifact.ts; P0 findings are formatted by renderFindingsForAgent() and fed back to the agent on the next turn as a system message. The agent self-corrects. This closes the loop between style enforcement and prompt context — instead of yelling at the user, the linter coaches the agent.

2. Notable design tradeoffs

Spawn the user's CLI vs run our own loop

OD's central bet (docs/agent-adapters.md:7-9):

"The code-agent space has already converged on a few strong implementations. Reimplementing another one is worse than just talking to all of them."

Pros:

Cons:

Plain files for artifacts; SQLite for metadata

Artifacts live in .od/projects/<id>/ as real files. SQLite stores projects, conversations, messages, tabs, templates, preview_comments, deployments only. Architectural rationale (docs/architecture.md:170-175): plain files are git-add-able, reviewable in PRs, greppable; SQLite would make artifacts opaque.

The trade is more code in projects.ts (path traversal, deletion, sanitization) and a small DB-vs-FS sync concern when a row references a missing file. The team decided that's worth it.

// @ts-nocheck on the daemon hot files

apps/daemon/src/server.ts:1 and agents.ts:1 opt out of strict TypeScript checking. Strict packages (contracts, sidecar-proto, sidecar, platform) supply types at the boundary. Inside the daemon, the JSDoc-driven dynamic style is what you'd expect from Express handlers and spawn() plumbing.

The risk is type drift inside the daemon. The mitigation is the contracts package — anything web-facing has to round-trip through typed DTOs.

Prompt size vs determinism

The prompt stack is large. Stacking discovery + official base + DESIGN.md + craft + skill + metadata + framework + media contract can run to thousands of tokens before the user message. The team explicitly trades context for determinism — every direction-pick avoids a regeneration; every checklist pass avoids a re-prompt.

Mitigations in code:

Auto-approve permissions on every spawned CLI

A deliberate, explicit choice (agents.ts:60-65): the web has no terminal, so an interactive permission prompt would deadlock. Therefore every CLI runs with auto-approve flags (bypassPermissions, dangerously-skip-permissions, full-auto, --allow-all-tools, --yolo).

The sandbox is the project cwd plus the explicitly allowlisted extraAllowedDirs (skills, design systems). Not a strong sandbox; not pretending to be. The honest framing: the agent has full read/write inside .od/projects/<id>/, and that's the perimeter.

One single-page web app

Next.js 16 App Router but everything is [[...slug]]/page.tsx returning <ClientApp />. Client-side routing via apps/web/src/router.ts. Why bother with Next.js? SSR for marketing pages (planned), Vercel deploy as a first-class citizen, and a static export path that the daemon serves directly. The team accepts the build-step cost for the deploy story.

3. Clever or unusual things

<question-form> as a syntactic primitive

The agent emits an XML-flavoured custom element with a JSON body. The web parses it (apps/web/src/components/QuestionForm.tsx) and renders a real form. The user fills it; the next turn arrives with [form answers — discovery]\n…. This is essentially a structured-output protocol implemented in the chat surface — without any tool-use plumbing on the agent side. Any agent that can emit XML-ish text (i.e. all of them) gets the structured-input UX for free.

data-od-id for comment-mode targeting

Skills are encouraged to tag sections/components with data-od-id="hero". When the user clicks a section in the iframe preview, comments.ts captures the data-od-id (or generates a snapshot selector). The next chat turn carries that as a comment-attachment so the agent does a surgical edit. This is one of the linter's P2 advisories — missing data-od-id blocks comment-mode targetability, so the linter actually quantifies how reviewable a generated artifact is.

Direction library is two payloads in one file

prompts/directions.ts is consumed twice from the same module: renderDirectionFormBody() produces the picker JSON the user sees, renderDirectionSpecBlock() produces the spec the agent binds. Same source of truth for what the user picks and what the agent applies. Add a direction → it shows up in the form and in the agent's spec block automatically.

od media generate as the agent-side adapter

Instead of writing a per-CLI tool ("Claude Code uses media_gen, Codex uses image_gen, …"), OD ships the daemon a CLI subcommand. The agent shells out to od media generate ... regardless of which agent is in play. The daemon injects OD_DAEMON_URL and OD_PROJECT_ID into the spawn env so the subcommand can phone home over loopback (server.ts:2016-2026). This is the unification trick — it makes one media implementation work across 12 CLIs.

Pre-flight directive as a context-pressure mitigation

prompts/system.ts:388-397 detects when the skill body references seed files (assets/template.html, references/layouts.md, references/checklist.md) and injects a hard "Pre-flight (do this before any other tool): Read X, Y, Z" directive above the skill body. The skill body itself already says this — but it gets truncated under context pressure, and the agent skips Step 0. The pre-flight rule is the team's response to a real failure mode they saw in production.

Clamping hallucinated CLI args

media.ts:158-190 clampNumber() snaps user-supplied (or model-hallucinated) duration / length / image-count to the nearest allowed bucket. Without it, an agent that hallucinates --length 9999 could trigger a month-long Volcengine billing event. agents.ts:78-95 clampCodexReasoning() does the same for reasoning-effort across model-specific valid sets (gpt-5.5 rejects "minimal", gpt-5.1 rejects "xhigh", gpt-5.1-codex-mini accepts only medium/high).

Triple-backtick escape with zero-width-space

system.ts:333 safe = tpl.prompt.replace(/```/g, '')` — a clever prompt-injection defense. If a user pastes triple-backticks into an editable prompt template, raw inclusion would break out of the surrounding fence and inject free-form instructions into the agent's system prompt. Inserting zero-width-space characters between the backticks defeats the fence-break while keeping the visible text close to the user's original.

Chat-run service is in-memory only

A failure in the daemon kills all in-flight runs, but the transcript survives because every emitted event is also persisted to messages.events_json. On reconnect, the browser fetches the message and replays the events through the same renderer it would use for a live SSE stream. One renderer, two sources — clean.

4. What's brittle