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:
tools-dev statusdiscovers live processes by stamp — no PID file, no port lockfile.namespacelets two concurrent local sessions exist on one machine without colliding (different IPC sockets, different.tmp/paths).sourceseparates "spawned by tools-dev" from "spawned by tools-pack" from "packaged".- Stamps live in process argv, so they're visible to
psand immune to env-var leakage between unrelated children.
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:
- Free model swaps, free tool implementations, free permission systems.
- The agent's vendor maintains the auth flow, prompt cache, context window, retry logic.
- Skills are cross-vendor: a Claude Code skill works in OpenCode (where the CLI supports skills natively).
Cons:
- Twelve different stream formats to parse.
- Permission posture differs per CLI; no shared sandbox model.
- Agent capabilities differ — comment mode degrades for weak agents.
- Detection is fragile across PATH/auth states; first-run UX has many "install X / authenticate Y" branches.
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:
- DESIGN.md sections can be pruned via
od.design_system.sections(only inject color + typography for some skills) —docs/skills-protocol.md:106. - Template body capped at 12 KB per file (
system.ts:362). - Prompt-template body capped at 4 KB (
system.ts:334-336). - Reference files dropped silently if missing (
craft.ts:21-46).
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
- Stream format pinning. Each parser knows the upstream JSON schema by version. When Claude Code updates
stream-json, the daemon needs an update. The capability probe (agentCapabilitiesMap) helps for known flag changes; nothing protects against silent schema changes mid-line. - No skill watcher in production. Adding a skill requires a daemon restart (or a request that triggers a re-scan). Fine in dev with
chokidar, but not implemented for prod. - Auto-approve everything is a hard floor on safety. A malicious skill could shell out via the agent's Bash tool. The README and architecture doc both flag this as out-of-scope for v1; users are expected to treat skills like any other npm-style supply chain.
- In-memory runs map. No backpressure on concurrent runs; if 50 browser tabs hit
/api/chat, 50 child processes spawn. There's no run quota. - Single SQLite file. Schema migrations are forward-compatible ALTERs in code (
db.ts), no migration tool. Fine for a single-user MVP; meaningful state changes will force a manual mop-up at some point.