What this tutorial teaches: not how to write another “coding terminal agent,” but how to build a host that treats someone else’s Agent as the engine and whose job is to produce real design files (HTML / PDF / PPTX / MP4)—the Open Design kind (
nexu-io/open-design, Apache-2.0, 82k stars in three months). The most counter-intuitive point: on this route you write zero lines of the Agent main loop. What you write is “socket + script + gates.” How it is taught: like this repo’s Manus lineage and OpenWorker lineage tutorials—follow one real scene, start from a blank page, and every time you hit a wall, add one part. Against source: every part cites Open Designfile:line, baseline commitc893b60(2026-07-28). Companion reading: open-design source analysis. Prerequisite: knowing “Agent loop = the model speaks → software acts for it → results are fed back → until it stops requesting tools” is enough. You do not need to know how to design.
Contents
- Look at the end first: what a “design-engine host” looks like
- Step 0 · The scene: one sentence, “make us a landing page”
- Step 1 · Decide not to write an Agent: use the already-installed CLI as the engine
- Step 2 · Adapter is data: one object literal describes one CLI
- Step 3 · Probe: how you know what is on the machine (and a pit you will hit)
- Step 4 · Launch: spawn a child; the prompt goes on stdin, not argv
- Step 5 · Understand its speech: four stream formats, one unified event set
- Step 6 · Catch the artifacts: two execution profiles
- Step 7 · Give it a brand: the
DESIGN.mdcontract and token-injection order - Step 8 · Give it craft: the fourth axis
craft/ - Step 9 · Give it a script: three hard rules
- Step 10 · Control priority: the prompt is a “who overrides whom” game
- Step 11 · Make it cheap: band by change frequency + cache-hit attribution
- Step 12 · First gate: write “looks fake at a glance” as regex
- Step 13 · Second gate: a five-panelist critique theater
- Step 14 · Make it remember you: a dual memory loop of three cards
- Step 15 · Make it extensible: four planes + atoms + a closed
until - Step 16 · Fit the boundary: you gave up the permission gate, so you must complete the perimeter
- Step 17 · Ship it as a product: desktop shell + sidecar + MCP server + export
- 🎬 Full replay: what that one sentence actually ran
- Appendix A · Acceptance assertions (how to verify each step)
- Appendix B · Twelve easiest ways to crash
- Appendix C · Source index
Look at the end first: what a “design-engine host” looks like
Before the first line of code, see what you are assembling. The boundary between a coding Agent and a design-agent host is not the model. It is these five things:
- It does not reason itself—reasoning is the
claude/codex/cursor-agentalready on your machine; - Its artifacts are for humans to look at, not for a compiler, so besides “is it correct” there is “does it look good”;
- A large fraction of “looking good” can be programmatically enforced (Tailwind indigo, two-stop gradients, emoji as icons, ALL CAPS without tracking);
- Brand is a versionable contract, not a scatter of CSS variables;
- Deliverables are real files, previewable, exportable, droppable into Cursor to keep coding.
Draw those five into an assembly diagram:
flowchart TB
subgraph UI["① What you can see"]
CHAT["chat + questionnaire cards"]
FW["file workspace"]
PREV["sandboxed iframe preview"]
end
subgraph HOST["② The host (all the code you write)"]
REG["runtime registry
26 adapter data defs"]
COMP["prompt assembler
20+ layers, banded by change frequency"]
LINT["anti-AI-slop linter"]
JURY["five-panelist critique theater"]
SSE["unified event stream (SSE)"]
end
subgraph CONTENT["③ Four content planes (filesystem, versionable)"]
SK["skills/ functional skills"]
TP["design-templates/ render templates"]
DS["design-systems/ brand contracts"]
CR["craft/ general craft rules"]
end
subgraph ENGINE["④ The engine (not your code)"]
CLI["claude / codex / cursor-agent /
copilot / opencode / …"]
end
CHAT --> SSE
SSE --> COMP
COMP --> CONTENT
COMP -->|assembled system prompt| REG
REG -->|spawn, cwd = project workspace| CLI
CLI -->|native Write/Edit tools| FILES["project files (real disk)"]
CLI -->|stdout| SSE
FILES --> FW --> PREV
FILES --> LINT -->|artifact-lint system reminder| CLI
FILES --> JURY -->|round summary| CLI
Remember this figure. Every step below fills one box.
Step 0 · The scene: one sentence, “make us a landing page”
Our design Agent only needs to do one thing well (do one thing thoroughly first):
“Make us a SaaS product landing page, using our company’s brand.”
Inside that sentence are every wall we will hit:
| The user says | The wall | Which step adds the part |
|---|---|---|
| “make me one” | Who reasons? Do I write the main loop? | Steps 1–2 |
| (what is installed?) | Is there claude? Right version? Logged in? |
Step 3 |
| (the prompt is 40 KB) | argv cannot hold it; Linux E2BIG, Windows ENAMETOOLONG |
Step 4 |
| (different CLIs, different output) | Claude emits JSONL; DeepSeek emits raw text | Step 5 |
| (where is the artifact?) | Some CLIs write files; some only emit text | Step 6 |
| “using our company’s brand” | How does brand become something the model can eat? | Step 7 |
| (it looks AI-made at a glance) | General typesetting common sense is in no brand doc | Step 8 |
| (15 seconds of silence, the user leaves) | Time to first byte | Step 9 |
| (rules fight each other) | “turn 1 must ask direction” vs “a design system is already selected, do not ask again” | Step 10 |
| (tens of thousands of tokens per turn) | Prefix cache | Step 11 |
| (purple gradient + 🚀 icon) | Programmatic enforcement of aesthetic quality | Steps 12–13 |
| (the user says “no beige” for the third time) | Memory | Step 14 |
| (someone wants their own template) | Extension mechanism | Step 15 |
(you fed --yolo to the child) |
Boundary defense | Step 16 |
| “export as PDF for the boss” | Productization | Step 17 |
Step 1 · Decide not to write an Agent: use the already-installed CLI as the engine
1.1 First, an honest cost accounting
You are building a design product. Every user interaction is “say a sentence → see a page.” To support that you need an Agent loop that can read files, write files, run commands, and iterate for many turns.
Plan A: write the loop yourself. You must handle: model calls and retries, tool definition and parse, context compaction, permission confirmation, resume from a breakpoint, cancel, cost accounting, multi-provider compatibility, prompt cache. That is roughly 8 000–20 000 lines (compare the scale of the other nine projects in this series). After you finish, your loop is probably worse than Claude Code or Codex—because those are what a full-time team spent a year on.
Plan B: call the one the user already installed. You must handle: probe, launch, argument construction, stream parse. About 3 000 lines.
Open Design chose B, and wrote the reason into the docs (docs/agent-adapters.md:9):
Thesis: The code agent space has already converged on strong implementations… Reimplementing another one is worse than talking to all of them.
1.2 The cascade of that decision (you must think it through in advance)
Choosing B is not “less work.” It is a different set of problems:
| You do not have to do | You must accept |
|---|---|
| Main loop, tool system, context compaction | You cannot control how it thinks—you can only influence via the system prompt |
| Model compatibility, retries, cost accounting | Your permission model is gone—the child’s permissions are its own |
| Resume from a breakpoint | You must handle 25 different stdout formats |
| Multi-provider abstraction | If the user has not installed a CLI, you cannot run (so you still need a BYOK fallback) |
The most important sentence: since you cannot control reasoning, your only lever is context engineering + output gates. So 60% of this tutorial is about prompts and gates—that is your actual product.
✅ This step’s acceptance: you can answer in one sentence “why we do not write the main loop,” and you can list three new problems the decision creates.
Step 2 · Adapter is data: one object literal describes one CLI
2.1 First, the wrong approach
You need to support 25 CLIs. The first instinct is an abstract base class:
// ❌ Do not write this
abstract class AgentAdapter {
abstract detect(): Promise<boolean>;
abstract run(prompt: string): AsyncIterable<Event>;
abstract cancel(): void;
}
class ClaudeAdapter extends AgentAdapter { /* 200 lines */ }
class CodexAdapter extends AgentAdapter { /* 200 lines */ }
// × 25 = 5000 lines, and each implements cancel, timeout, error classification itself
The problem is not code volume. It is behavior drift: 25 run()s will have 25 cancel semantics, 25 timeout treatments, 25 error classifications. Six months later you will find Cursor’s cancel is unclean and Copilot’s timeout never fired—because nobody can maintain 25 copies of lifecycle code at once.
2.2 The right approach: fully separate “how to talk to it” from “how to schedule it”
// ✅ One pure data object + one pure function
type RuntimeAgentDef = {
id: string; // unique key
name: string; // display name
bin: string; // executable probed on PATH
versionArgs: string[]; // version-probe args
fallbackModels: RuntimeModelOption[]; // static fallback when models cannot be discovered
buildArgs: (prompt, imagePaths, extraDirs?, options?, ctx?) => string[]; // the only function, and it is pure
streamFormat: string; // the engine dispatches a parser from this
// …~38 optional fields, all data
};
Six required fields, one pure function. Everything else is done by the shared engine:
flowchart LR
DEF["RuntimeAgentDef
(pure data)"] --> E1["detection.ts
probe"]
DEF --> E2["launch.ts
path resolve + env"]
DEF --> E3["invocation.ts
argv assemble + spawn"]
DEF --> E4["run lifecycle
cancel / timeout / error class"]
DEF -->|streamFormat dispatch| E5["one of 4 parsers"]
style DEF fill:#e8f5e9
Against source: the contract is at apps/daemon/src/runtimes/types.ts:101-253; 26 definitions live in apps/daemon/src/runtimes/defs/.
2.3 Write your first definition
Use Claude Code as the sample (defs/claude.ts, 98 lines in full):
export const claudeAgentDef = {
id: 'claude',
name: 'Claude Code',
bin: 'claude',
fallbackBins: ['openclaude'], // ① argv-compatible fork; if PATH has no claude, try in order
versionArgs: ['--version'],
authProbe: { args: ['auth', 'status'], timeoutMs: 5000 }, // ② declarative auth probe
helpArgs: ['-p', '--help'], // ③ note: `claude -p --help`, not `claude --help`
capabilityFlags: { // ④ substring → capability key
'--include-partial-messages': 'partialMessages',
'--add-dir': 'addDir',
},
fallbackModels: CLAUDE_FALLBACK_MODELS,
buildArgs: (_prompt, _imagePaths, extraAllowedDirs = [], options = {}, ctx = {}) => {
const caps = agentCapabilities.get('claude') || {};
const args = ['-p', '--input-format', 'stream-json',
'--output-format', 'stream-json', '--verbose'];
if (caps.partialMessages) args.push('--include-partial-messages'); // ⑤ capability gate
if (options.model && options.model !== 'default') args.push('--model', options.model);
const dirs = extraAllowedDirs.filter(d => typeof d === 'string' && d.length > 0);
if (dirs.length > 0 && caps.addDir !== false) args.push('--add-dir', ...dirs);
if (ctx.resumeSessionId) args.push('--resume', ctx.resumeSessionId); // ⑥ session resume
else if (ctx.newSessionId) args.push('--session-id', ctx.newSessionId);
args.push('--permission-mode', 'bypassPermissions');
return args;
},
promptViaStdin: true, // ⑦ prompt goes on stdin (next step)
promptInputFormat: 'stream-json',
streamFormat: 'claude-stream-json',
externalMcpInjection: 'claude-mcp-json',
resumesSessionViaCli: true,
} satisfies RuntimeAgentDef;
Why, field by field:
① fallbackBins: the ecosystem will grow argv-compatible forks (issue #235: a user only had OpenClaude). One array solves it; the user does not write a wrapper script.
③ helpArgs: ['-p', '--help'] is a real bug fix (comment at claude.ts:33-38): --add-dir and --include-partial-messages only appear in claude -p subcommand help; probing claude --help never finds them (issue #430).
④+⑤ Capability gating: old CLI versions do not know new flags; passing them yields “unknown option” exit 1 and kills the chat. So every optional flag must be probed before use. capabilityFlags is “substring in help output → capability key”; after probe it is written into an in-memory map; buildArgs reads it.
⑥ Session resume: do not reassemble the whole conversation for the CLI every turn. Let the CLI keep its own session, and it keeps working memory (which files it read, what it changed, tool history). Distinguish three styles (types.ts:189-209):
| Field | Who generates the session id | Example |
|---|---|---|
resumesSessionViaCli |
You generate it, tell the CLI to use it | claude --session-id <uuid> |
capturesSessionIdFromStream |
The CLI generates it, reports it on the stream, you capture and store | codex’s thread.started.thread_id |
resumesSessionViaAcpLoad |
Take a durable id from an ACP session | AMR/Vela use session/load |
⚠️ A pit you will hit: if a CLI already has its own session memory (e.g.
agy -c) and you also splice the rendered web transcript into the user message, it receives two copies of the previous turn—including the raw<question-form>it emitted on turn 1. The model pattern-matches that raw text and emits the questionnaire again on turn 2, which looks like a stuck loop. The fix is not changing the prompt; it is an opt-out flag that skips transcript injection (types.ts:70-84records this autopsy).
2.4 Registry: 81 lines + one load-time invariant
// registry.ts:30-76
const BASE_AGENT_DEFS: RuntimeAgentDef[] = [ /* 26 entries */ ];
export const AGENT_DEFS = [...BASE_AGENT_DEFS, ...readLocalAgentProfileDefs(BASE_AGENT_DEFS)];
const ids = new Set();
for (const def of AGENT_DEFS) {
if (ids.has(def.id)) throw new Error(`Duplicate agent definition id: ${def.id}`);
ids.add(def.id);
}
That throw runs at module load. A user-defined profile that collides with a built-in id → the daemon will not start, rather than silently overwrite a built-in adapter. Make errors early and loud.
2.5 The contract must be restrained: be explicit about what it does not contain
This matters more than “what it contains.” Open Design’s contract deliberately has no:
- ❌
nativeSkillLoading/skillInjectionStrategy— skill delivery is shared behavior, not a per-adapter strategy - ❌ a
capabilities()method —capabilities.tsis 131 bytes in full; it is a flag map - ❌ a
surgicalEdit/streaming/resumefeature-gate table — that becomes a capability matrix that never lines up
Judgment: if a field describes “what this CLI looks like,” put it in the definition; if it describes “what to do when this happens,” put it in the engine.
✅ This step’s acceptance: adding a CLI that uses an existing wire format should be one new file + one line in the registry, zero engine-code change. The smallest definition in Open Design is
defs/kilo.ts, 629 bytes.
Step 3 · Probe: how you know what is on the machine (and a pit you will hit)
3.1 Probe pipeline
flowchart TB
S["probe(def, configuredEnv)"] --> R["① resolveAgentLaunch(def, env)
resolve 'the path that will actually be spawned'"]
R -->|cannot resolve| U1["unavailable + executable diagnosis"]
R --> V["② version probe (on that path)"]
V -->|OS-level missing/not executable| U2["unavailable + not-invocable diagnosis"]
V -->|starts but refuses --version| OK1["✅ available, version = null"]
V -->|success| P
OK1 --> P
P["③ three post probes concurrent Promise.all"] --> C1["--help capability table"]
P --> C2["model discovery"]
P --> C3["auth probe (only if declared)"]
C1 & C2 & C3 --> OUT["DetectedAgent"]
3.3 Three secondary details that will save you
① Version-probe three-state:
| Result | Judgment | Why |
|---|---|---|
| OS-level missing / not executable | unavailable | truly not there |
Starts, but refuses --version |
available, version left empty | some CLIs renamed the version flag; do not kill them |
| Success | available + version | — |
② Concurrent but ordered: version probe must finish first (it decides availability); the later three probes are independent of each other and run concurrently. detection.ts:273-277: “a single agent's detection wall is max(help, models, auth) ≈ 5s rather than the sum ≈ 15s”.
③ Per-adapter fault isolation: a naked Promise.all rejects the whole if one rejects—one broken executable must not empty the whole selector.
3.4 Auth: do not guess
Old practice: look whether ~/.foo/ exists and guess login. The cost of guessing wrong is painting a working agent red.
New rule (docs/agent-adapters.md:126-128):
- Only adapters that declared
authProbeactively probe auth - Those that did not →
authStatusis unknown; do not synthesize a failure - Real auth problems are inferred only from error text of a real run failure (
classifyAgentServiceFailure)
The probe must be a cheap, side-effect-free status/whoami-class command. Claude is ['auth', 'status'] + 5-second timeout.
3.5 Probe UX
GET /api/agents?stream=1: one SSE event per finished probe, thendone. The settings panel can start drawing cards without waiting for the slowest CLI.- No 24-hour cache. Every call re-probes concurrently. Prefer a few extra seconds over showing the user a stale state (the user just
npm i -g’d; a refresh should show it).
✅ This step’s acceptance: move
claudeoff PATH → that card greys out but the other 25 are unaffected; install a shim-version Codex via nvm → probe result and whether it actually runs agree.
Step 4 · Launch: spawn a child; the prompt goes on stdin, not argv
4.1 Why the prompt cannot go on argv
How large is the assembled system prompt? Do the arithmetic:
| Fragment | Magnitude |
|---|---|
| Designer charter | ~4 KB |
| Discovery layer + design philosophy | ~12 KB (~3 000 tokens) |
| Direction library (when no design system is selected) | ~6.7 KB |
Active design system (DESIGN.md + tokens.css + component inventory) |
8–40 KB |
| Craft craft rules (3 slugs) | ~6 KB |
| Active skill body | 2–15 KB |
| Total | 30–80 KB is common |
And OS limits are:
| Platform | Limit |
|---|---|
| Linux | MAX_ARG_STRLEN limits a single argv entry to ~128 KB → spawn E2BIG |
| Windows | CreateProcess limits the whole command line to ~32 KB (~8 KB through a .cmd shim) → spawn ENAMETOOLONG |
So the rule is simple: if the CLI supports stdin, use stdin (promptViaStdin: true). The first parameter of buildArgs will be _prompt—underscore, because it is unused.
4.2 Three delivery methods
| Field | Delivery | When |
|---|---|---|
promptViaStdin: true |
write into the child stdin | default first choice |
promptViaFile: true |
write a temp file; path given to buildArgs via ctx.promptFilePath |
the CLI has an explicit prompt-file flag |
| (neither set) | onto argv | the CLI hard-requires a positional (e.g. DeepSeek’s clap declares prompt: String required) |
The third needs extra guards; see step 16.
4.3 stream-json input format: why stdin stays open
Claude’s definition has promptInputFormat: 'stream-json'. types.ts:125-130 explains:
When set to
'stream-json'the daemon writes a single JSONL line wrapping the prompt as an Anthropic user message (so tool_result blocks can later be injected into the same stdin without re-spawning the child).
Paired with docs/agent-adapters.md:191-194:
Stdin remains open so the daemon can forward additional user messages mid-turn, then is closed after a clean terminal
turn_end/usagerather than at a mid-tooltool_usepause.
This rule will bite you: if you close stdin when you see stop_reason: tool_use, Claude thinks the conversation ended and the turn cuts mid-way. apps/daemon/AGENTS.md:113 has a dedicated rule: “do not close stdin on tool_use stop reasons”.
4.4 cwd is the project workspace
spawn(resolvedBin, args, { cwd: projectWorkspaceDir, env: spawnEnv })
cwd is an execution root, not a sandbox (step 16 will emphasize this again). The child’s Write/Edit land directly here; your file workspace watches this directory for changes.
4.5 A watchdog for a stuck turn
A child may emit no bytes for a long time (e.g. Copilot’s thinking phase while generating a deck). So there must be an inactivity timeout based on stdout/stderr/SSE activity, global default 10 minutes; an adapter can declare a longer ceiling with inactivityTimeoutMs; an operator can override with OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS (env wins).
The comment at types.ts:219-226 names the essence: “The watchdog observes child stdout/stderr/SSE activity, not real CPU progress”—you observe “is it speaking,” not “is it working.”
✅ This step’s acceptance: construct a 60 KB prompt; it runs to completion on macOS + Windows; the stream does not cut mid-tool-call.
Step 5 · Understand its speech: four stream formats, one unified event set
5.1 Classify, do not enumerate
25 CLIs, 25 stdout formats? No. After classifying by wire format there are only 7 streamFormats, 4 parser classes:
streamFormat |
Who uses it | Parser |
|---|---|---|
claude-stream-json |
claude · amp · codebuddy | claude-stream.ts |
json-event-stream |
codex · cursor-agent · opencode · mimo · byok-opencode | json-event-stream.ts (further dispatch by eventParser) |
copilot-stream-json |
copilot | copilot-stream.ts |
qoder-stream-json |
qoder | qoder-stream.ts |
acp-json-rpc |
9 entries: amr · devin · hermes · kimi · kiro · kilo · reasonix · trae-cli · vibe | agent-protocol/acp/ |
pi-rpc |
pi | agent-protocol/pi-rpc/ |
plain |
aider · antigravity · atomcode · deepseek · grok-build · qwen | plain-stream.ts |
Nine ACP adapters share one transport—that is why “adding one ACP agent is one 629-byte object.”
5.2 Unified event set
All parsers emit the same events: thinking / tool-call / tool-result / text-delta / file-write / error / done. The UI only knows this set, not the underlying format.
Note: this event set is defined by the parser, not by the def (docs/agent-adapters.md:107). The def only says “I am this class”; how to translate is the parser’s job.
5.3 plain stream: the weakest adapter needs the most code
A raw-text CLI has no structured file-write events. The convention is to have it emit Anthropic-style source blocks:
<artifact identifier="landing-page" type="text/html" title="Landing page">
<!doctype html>
<html>...</html>
</artifact>
Then scan stdout at run end and extract. Sounds like a five-minute job; in fact plain-stream.ts is 473 lines, because of four pits:
Pit 1 — Fake <artifact> inside Markdown fences. When the model explains “you should write it like this,” it puts <artifact> inside ``` or backticks. A naïve indexOf will write teaching examples to disk as real artifacts.
Fix (plain-stream.ts:252-306): first compute skip ranges. Fences are judged line by line with /^```(\w[\w+-]*)?\s*$/ and /^```\s*$/; inline backticks must match the same number of consecutive backticks.
🔑 Companion requirement: this fence logic must stay consistent with the browser-side artifact parser (
apps/web/src/artifacts/markdown-context.ts)—otherwise what a headless run writes to disk and what a browser parses will disagree.
Pit 2 — Prefix misjudgment. <artifacts> is not <artifact>. Judgment: after the open tag there must be whitespace.
function isRealArtifactOpenAt(text, idx) {
return /\s/.test(text.charAt(idx + '<artifact'.length));
}
Pit 3 — > inside an attribute value. title="A > B" will be truncated by indexOf('>'). Use a quoted-state machine to find the end of the open tag (plain-stream.ts:235-250).
Pit 4 — Nested / unclosed. Each time you find an open tag, first probe the position of the next open tag: if it appears before the current open tag ends, or before the close tag, the current one is broken; skip to the next and retry. One malformed output must not swallow every later legal artifact.
Plus two bomb defenses: MAX_ARTIFACTS_PER_RUN = 50; filename collisions add -2/-3 (cap 10 000).
5.4 When writing to disk, also write an “artifact inventory”
Do not only write files. For each artifact, generate a sidecar inventory by extension:
// .html → { kind:'html', renderer:'html', exports:['html','pdf','zip'], primary:true, metadata:{…} }
// .css → { kind:'code-snippet', renderer:'code', exports:['txt','zip'] }
// .svg → { kind:'svg', renderer:'svg', exports:['svg','zip'] }
// .md → { kind:'markdown-document', renderer:'markdown', exports:['md','html','pdf','zip'] }
This inventory tells downstream which renderer to use, what it can export as, which file is the entry. Without it, your file workspace can only guess from the extension.
✅ This step’s acceptance: feed the parser a stdout containing “a fake artifact inside a code fence + a real artifact + one unclosed bad artifact”; only the real one should land on disk.
Step 6 · Catch the artifacts: two execution profiles
6.1 A 249-byte file’s problem
CLI capability varies widely: Claude Code has full Read/Write/Edit/Bash; DeepSeek TUI in the invocation mode you chose will only emit text. You cannot write a delivery logic per adapter.
Abstract into two grades:
export type ExecutionProfile = 'filesystem' | 'text_artifact';
export function executionProfileFromStreamFormat(streamFormat) {
return streamFormat === 'plain' ? 'text_artifact' : 'filesystem';
}
6.2 The two profiles’ prompt contracts are opposites
flowchart TB
RUN["one generation"] --> P{"execution profile"}
P -->|filesystem| F1["CLI writes project files with native tools"]
F1 --> F2["file events → file workspace → preview"]
F2 --> F3["assistant ends with an ordinary summary
❌ must not also emit an <artifact> source block"]
P -->|text_artifact| T1["the model loop has no file tools"]
T1 --> T2["the only delivery form: one complete <artifact> block"]
T2 --> T3["after the run, the host scans stdout, extracts, writes to disk"]
T3 --> F2
filesystem-profile prompt (FILESYSTEM_HANDOFF_OVERRIDE):
- Do not output generated source code in a
<artifact type="text/html">block.- Do not duplicate file contents in assistant text after writing them to disk.
- A filesystem run that emits a source-code
<artifact>is treated as an unexpected fallback by the host.
text_artifact-profile prompt (API_MODE_OVERRIDE):
No tools are wired through to you.
TodoWrite,Read,Write,Edit,Bash, andWebFetchare unavailable — calls to them will not execute and will not render in the UI. The override does NOT block<artifact>blocks — those are how the web UI receives finished HTML in API mode.
6.3 A real bug: what happens without a top-anchored override
contracts/src/prompts/system.ts:296-307 records issue #313:
…the discovery layer + base prompt below still tell it to call TodoWrite/Read/Write/Edit/Bash/WebFetch. Without an explicit top-anchored override, the model invents pseudo-tool markup (
<todo-list>,[读取 X]) instead of producing real progress events.
The model will not say “I do not have this tool.” It will pretend to call it—emit <todo-list>... pseudo-markup. The UI renders nothing; the user sees garbage.
Fix: pin the API-mode override at the absolute top—even before the discovery layer. Because the discovery layer itself starts with “the following rules override everything later,” you must sit on top of it.
⚠️ This is the tutorial’s first “prompt-priority game”; step 10 treats it systematically.
6.4 An architectural regression worth learning from
Early Open Design implemented a fallback loop in the daemon: “talk to Anthropic directly + implement Read/Write/Edit yourself.” It is now fully deleted, replaced by a byok-opencode profile—translate BYOK credentials into OpenCode config, and let the installed opencode process keep owning the model/tool loop.
docs/agent-adapters.md:215-218:
There is no daemon-owned fallback loop and no daemon implementation of
Read/Write/Edittools.
Why it is worth learning: that fallback loop violated the fundamental claim “we do not implement an Agent loop.” Once you open that hole, it slowly grows into a second (worse) Agent. Deleting it was correct.
✅ This step’s acceptance: run the same brief on a
plainadapter; the artifact files should land in the same place, with the same inventory, as a filesystem-profile run.
Step 7 · Give it a brand: the DESIGN.md contract and token-injection order
From this step on we leave “how to call a CLI” and enter the actual product.
7.1 Brand is not a pile of CSS variables; it is a versionable contract
Minimal package shape (three files, all required):
design-systems/<slug>/
├── manifest.json ← discovery metadata, provenance, declared in-package paths
├── DESIGN.md ← prose spec for the agent (canonical)
└── tokens.css ← compiled semantic-token stylesheet (canonical)
Key constraints of manifest.json:
{
"schemaVersion": "od-design-system-project/v1",
"id": "acme", // must equal the folder slug, normalized ASCII
"name": "Acme",
"category": "Productivity & SaaS",
"description": "A concise English catalog summary.",
"source": { "type": "bundled", "origin": "…" },
"files": { "design": "DESIGN.md", "tokens": "tokens.css" } // fixed filenames
}
Every declared path must be safe, relative, and exist—that is what the guard checks.
7.2 Rich files are a cache, not a competing source of truth
The package can carry more files, but you must distinguish primary from derived:
| File | Nature |
|---|---|
DESIGN.md · tokens.css |
source of truth |
components.manifest.json |
derived from components.html + tokens.css |
design-tokens.json |
derived from the token-contract report; must agree with tokens.css |
tailwind-v4.css |
derived from tokens.css |
USAGE.md · assets/ · fonts/ · preview/ · source/ |
optional rich resources |
Derived-file consistency is checked by the guard. This rule prevents “someone edited tokens.css but forgot to regenerate tailwind-v4.css, and the model reads two conflicting values.”
7.3 Injection order: eight layers, and the order is meaningful
flowchart TB
L1["① package-specific USAGE.md (or the default usage contract)"] --> L2["② full DESIGN.md body"]
L2 --> L3["③ import-mode guidance (only if declared)"]
L3 --> L4["④ tokens.css"]
L4 --> L5["⑤ compact component inventory
(components.html if no inventory)"]
L5 --> L6["⑥ rich-file pull-on-demand index"]
L6 --> L7["⑦ craft craft rules"]
L7 --> L8["⑧ active skill/template body"]
style L2 fill:#e8f5e9
style L4 fill:#e8f5e9
Why USAGE first? It is the “how to read” note—telling the model how to use the pile below. The default usage contract is worth copying verbatim:
Read DESIGN.md for visual principles, paste tokens.css verbatim into the first
<style>when it is provided, and match component shapes from the reference component manifest or fixture when available. Treat any pull-layer index as optional context for deeper inspection; do not assume those files have already been loaded.
The last half-sentence prevents a specific hallucination: the model sees the index and assumes the files have already been read, then cites a component it never loaded.
Why craft after the design system and before skills? Because priority is brand tokens win conflicts > craft rules fill gaps > skills define workflow.
7.4 Three things you deliberately do not do
| Do not | Why |
|---|---|
| Do not copy the design system into the run cwd | It is a read-only reference, not a working copy; if you copy it in, the agent may edit it |
Do not support od.design_system.sections per-section clipping |
Clipping produces “the model only saw the palette, not the usage rules” out-of-context readings |
Do not use {{ design_system }} variable substitution |
Template variables induce skill authors to put the design system in the wrong place |
7.5 DESIGN.md quality bar: seven H2s, without prescribing titles
Upstream awesome-design-md used a nine-section fixed template. Open Design evolved to:
The package-quality guard requires at least seven substantive H2 headings for migrated packages, without prescribing their names, order, or numbering. Use headings that fit the actual system and keep their decisions synchronized with
tokens.css.
Keep the “must be substantial enough” bar; drop the “must be called these names” rigidity. This is a good paradigm for content-contract design: constrain density, not shape.
7.6 The user has no brand: five-step brand extract
In our scene the user said “use our company’s brand,” but may only give a URL or a screenshot. Then run extract:
- Locate the source: if there are attachments, list them; if a URL was given, WebFetch
<brand>.com/brand,/press,/about - Download style artifacts: CSS, brand-guide PDFs, screenshots
- Extract ground truth:
grep -E '#[0-9a-fA-F]{3,8}'for hex in CSS; screenshots are read visually for typesetting. Never guess a color from memory - Encode as a contract: write
brand-spec.md—six OKLch color tokens (--bg--surface--fg--muted--border--accent) + display/body/mono font stacks + 3–5 observed layout postures (radius, border weight, accent budget) - Say it back: one sentence that names the system you will use (“deep navy product canvas, a single electro-cyan accent at oklch(68% 0.16 220), geometric display + system body”), so the user can cheaply correct
One anti-hallucination hard rule: the user selected “I have a brand spec” but has not given a source → ask for the source and stop. Do not guess a brand domain. Do not invent tokens.
✅ This step’s acceptance: switch design systems; the next generation’s
:roottokens should swap as a whole; break one value intokens.css; the guard should report derived-file inconsistency.
Step 8 · Give it craft: the fourth axis craft/
8.1 Why a fourth axis
You now have three axes: skills (what to do), templates (what shape to make), design systems (which brand). What comes out still looks fake at a glance. Why?
Because there is a class of knowledge that belongs to no brand and to no skill:
- ALL CAPS always needs ≥0.06em tracking
var(--accent)appears at most twice per screen#6366f1is always the tell of an AI default color- display font and body font should not be the same family
These are a competent designer’s muscle memory. Write them into 151 DESIGN.mds? That is 151 copies, and one change is 151 edits.
So split a fourth axis:
| Axis | Scope | Examples |
|---|---|---|
skills/ |
capabilities invoked while working | brand-extract · web-clone |
design-templates/ |
packaged artifact shapes | saas-landing · dashboard |
design-systems/ |
brand packages | linear-app · apple |
craft/ |
general craft knowledge — brand-independent | tracking rules, accent-budget ceilings, anti-AI-slop |
8.2 Subscribe on demand, not inject everything
A skill declares in frontmatter which sections it needs:
od:
craft:
requires: [typography, color, anti-ai-slop]
Only listed paragraphs enter the prompt. A typesetting-only skill need not pay token cost for color and motion.
11 published slugs (craft/*.md):
| File | When you need it |
|---|---|
typography |
any skill that outputs text (≈ all) |
typography-hierarchy |
interfaces whose hierarchy must “look designed,” not “piled” |
typography-hierarchy-editorial |
long-read surfaces: blogs, docs, ebooks |
color |
any skill that outputs style (≈ all) |
anti-ai-slop |
marketing pages, landing pages, decks |
state-coverage |
stateful UI: dashboards, mobile apps, forms, lists/tables |
animation-discipline |
with motion: mobile apps, multi-screen flows, gamified UI, micro-interactions |
accessibility-baseline |
any interactive UI: focus, labels, keyboard paths |
rtl-and-bidi |
may render Arabic/Hebrew/Persian |
form-validation |
main artifact contains an interactive form: lead-gen, login, signup, settings, multi-step collect |
laws-of-ux |
combinatorial decisions that hit named cognitive limits: pricing pages (Hick’s / choice overload / Von Restorff), dashboards (Pareto / selective attention / working memory), onboarding (goal gradient / Zeigarnik / peak-end), modals (Fitts / Tesler) |
💡
laws-of-uxand the other files are sibling axes: the others govern “how to render”; it governs “what to combine.”
8.3 Two enforcement levels: honestly mark which are really checked
This is the most worth-copying design of the step:
| Level | Meaning | Who executes |
|---|---|---|
| Auto-checked | rules wired into the linter | lint-artifact.ts (step 12) |
| Guidance | the rest | the agent reads, reviewers use, the linter does not check |
And mark them one by one in the docs: in craft/anti-ai-slop.md P1/P2 subsections, every rule not wired into the linter is followed by “(guidance, not auto-checked)”.
Why this matters: if you write a “rulebook” of which only 30% is actually checked, and the docs pretend 100% is checked, then in six months nobody believes the book. Marking clearly actually makes the checked 30% more authoritative.
craft/README.md:70: “A purely behavioral craft file is guidance unless a specific rule is later promoted into lint-artifact.ts.” — the promotion path is explicit.
8.4 Runtime lenient vs repo strict
An important dividing line:
| Scene | Behavior |
|---|---|
| Runtime hits a missing craft slug | skip, do not error—an externally installed old bundle may reference a section the current resource set does not have; “A missing optional paragraph must not make an otherwise usable runtime bundle fail” |
| In-repo checked-in content references a missing slug | pnpm lint:craft and pnpm guard fail |
| Deliberate forward references | must be registered in craft/FUTURE_SECTIONS.md to be legal |
That FUTURE_SECTIONS.md is clever: it makes “planned but not yet written paragraphs” visible and registered, rather than letting a typo silently drop a whole prompt section.
✅ This step’s acceptance: write
requires: [typograpy]in a skill (deliberate typo) →pnpm lint:craftshould error and point at the manifest path; the same error at runtime should only skip that paragraph.
Step 9 · Give it a script: three hard rules
Now you have an engine, a brand, and craft. The user types “make us a landing page.” The model starts thinking… then 25 seconds later dumps a full HTML page with a beige background, a purple gradient, and three emoji icons. The user closes the tab and leaves.
The problem is not the model. You did not give it a script.
9.1 Timing of the three rules
sequenceDiagram
participant U as User
participant A as Agent
participant H as Your host
Note over A: RULE 1 · turn 1
U->>A: "Make us a SaaS landing page, using our company's brand"
A->>H: one short prose line + + stop
Note right of A: ❌ no file reads ❌ no Bash
❌ no TodoWrite ❌ no extended thinking
H->>U: render as a questionnaire card (every question prefilled with a recommended value)
Note over A: RULE 2 · turn 2
U->>A: "[form answers — discovery] brand: brand_spec …"
alt branch A: brand/reference source was given
A->>A: brand extract five steps (Bash / Read / WebFetch)
A->>H: write brand-spec.md
A->>U: one-sentence restatement of the system
else branch B: no brand source
A->>A: use the active design system / pick from the direction library yourself
Note right of A: ❌ never emit a second direction questionnaire
end
Note over A: RULE 3 · turn 3 onward
A->>H: TodoWrite nine-step plan
loop each completed step
A->>H: immediately mark completed, next step in_progress
end
A->>A: step 7 checklist.md (P0 must all pass)
A->>A: step 8 five-dimension self-score; any <3/5 rework
A->>H: step 9 deliver
9.2 RULE 1: turn 1 may only emit the questionnaire
The original rule (discovery.ts:40-44):
your very first output is one short prose line + a
<question-form>block. Nothing else. No file reads. No Bash. No TodoWrite. No native tool calls. No extended thinking. The form is your time-to-first-byte.
“The form is your time-to-first-byte”—that sentence names the product motive of the whole rule. Users will not tolerate 15 seconds of silence, but they fully accept a form that pops in 2 seconds and can be clicked through.
And you must block the model’s excuses (discovery.ts:154):
The form applies even when the user's brief looks complete. … Do not justify skipping it ("the brief is rich enough"); ask anyway. The user is fast at picking radios; they are slow at re-doing a wrong direction.
Only three cases may skip:
1. The user is making a small tweak inside an existing design (“make the title bigger”)
2. The user explicitly says “skip questions” / “just build” / “no questions, go”
3. The user message starts with [form answers — …] (answers already exist)
9.3 Six engineering details in the form-writing rules
Each of these was stepped on:
① Hard cap of 5 questions. “Before emitting, count the questions in your draft; if there are more than 5, delete the least build-critical until exactly 5 or fewer remain. A question earns its place only if its answer genuinely changes what you would build for THIS brief.”
② Every question must prefill default, and the default key must be written before options. The reason is purely engineering:
the host renders forms token-by-token, and a
defaultthat trails a longoptionsarray reaches the user late.
A JSON key-order requirement leaked from streaming render into the prompt—this class of “implementation detail leaking into the prompt” is entirely reasonable.
③ Display layer fully localized, control layer fully English.
Localize every user-facing string … write what a native speaker would naturally say, never a word-for-word translation (the Chinese title is 快速确认 · 30秒, not the literal 快速简报). …
id,type, optionvalue, and the stable branch values (pick_direction,brand_spec,reference_match) MUST stay in English because later branch rules match against them.
Later RULE 2 branches on value; if you localize them, they will not match.
④ Do not write an “Other” option yourself. The host automatically renders a localized “Other” escape hatch for every finite-option question (a chip that opens into an input). If the model writes one too, it duplicates. Only set allowCustom: false when a downstream system truly needs a precise machine id.
⑤ Metadata and plugin inputs are equally authoritative. This is the longest and most practical:
- “Project metadata” = what the user selected when creating the project (
kind,fidelity,speakerNotes,slideCount,animations,template,platform) - “Plugin inputs” = the same class of data brought in from a Home plugin chip
- If either source provided an answer → delete the corresponding default question; fields marked “(unknown — ask)” → add a question
- It even lists synonym-field maps:
platform/surface/platformTargets/targetall answer “target platform”;slideCount/slides/pageCountall answer “page count”;artifactKind/mode/taskKindalready say what we are making, do not also ask “what are we making”
⑥ Prefer rich controls. Available types: radio checkbox select text textarea number range date time datetime-local color url email tel file switch direction-cards. The rule is “use the most expressive mainstream control”: numeric intensity uses a slider, brand color uses color, a deadline uses date, upload uses file (in the same form; do not finish the form and then ask for files in prose).
9.4 RULE 2: four-step-priority branch resolution
1. A real brand source already exists in the current message/attachments/prior brief/URL → branch A
2. Otherwise look at the submitted brand value (use the stable value from [value: ...], not the visible label)
3. brand value is "brand_spec" or "reference_match" → branch A
4. Otherwise → branch B
Branch B explicitly forbids a second direction question:
Do not emit any second direction-picking form and do not make the user choose a direction after project creation. … If no active design system is present, pick the best-matching direction yourself from the Direction library and bind it without asking.
This is the sediment of a product decision: early versions popped a “pick one of five direction cards”; later they found one extra click was one extra dropout, and changed it to “pick yourself; the user can object if unhappy.”
9.5 RULE 3: nine-step plan + two non-negotiable gates
1. Read the active DESIGN.md + skill resources (template.html, layouts.md, checklist.md)
2. Bind tokens to :root (branch A uses brand-spec.md; if there is a design system, use it; otherwise a self-picked direction)
3. Plan the section/slide/screen inventory, including platform variants and rhythm (say it out loud before writing)
4. Copy the seed template to the project root
5. Paste and fill the planned layout
6. Replace [REPLACE] placeholders with real, specific copy from the brief
7. Self-check: run references/checklist.md (P0 must all pass)
8. Critique: five-dimension radar; any <3/5, fix
9. Deliver
Steps 7 and 8 are non-negotiable.
Five self-score dimensions (this is the human-readable version of step 13’s Design Jury):
| Dimension | Interrogation |
|---|---|
| Philosophy | Does the visual posture match the brief? Or did it drift back to your favorite default? |
| Hierarchy | Does each screen have one obvious eye landing? Or are all elements competing? |
| Execution | Type, spacing, alignment, contrast—correct, or merely “close enough”? |
| Specificity | Is every word, number, image this brief’s own? |
| Restraint | One accent at most twice, one decisive highlight—or three highlights fighting? |
“Any dimension under 3/5 is a regression. Go back, fix the weakest, re-score. Two passes is normal.”
Note that last sentence: by default you should rework two rounds. Take the unrealistic expectation of “one-shot finished” out of the process, and the model will not lower its self-score to ship once.
9.6 Decks: “framework first” iron law
If your product supports slides, this must exist:
Decks especially — framework first, content second. … copy the deck framework HTML verbatim before authoring any slide content. Do NOT write your own scale-to-fit logic, keyboard handler, slide visibility toggle, counter, or print stylesheet — every freeform attempt at this re-introduces the same iframe positioning / scaling bugs we have already fixed in the framework.
Implementation has three branches (each is the fossil of an incident):
const isDeckProject = skillMode === 'deck' || metadata?.kind === 'deck';
const isFreeformProject = !skillMode && (!metadata || metadata.kind === 'other');
const hasSkillSeed = !!skillBody && /assets\/template\.html/.test(skillBody);
if (isDeckProject && !hasSkillSeed) inject a generic skeleton;
else if (isFreeformProject && !hasSkillSeed) inject a skeleton with a conditional prefix;
// when there is a skill seed, do not inject—the seed has a more opinionated framework; duplicating conflicts
The second branch’s conditional prefix is worth copying:
If — and only if — the brief reads as slides, keynote, presentation, deck, PPT, or 讲解, follow the framework below. Otherwise ignore everything in this section and continue with the freeform output you would have written anyway.
Give a “may not apply” instruction an explicit applicability condition, and the model will not force-fit.
✅ This step’s acceptance: open a new project, send a vague brief → a questionnaire card appears within 2 seconds, every question prefilled; submit without changing → it should produce a reasonable artifact.
Step 10 · Control priority: the prompt is a “who overrides whom” game
10.1 Your prompt already has 20 layers
By this step you are stuffing into the system prompt: injection resistance, mode overrides, locale, discovery layer, direction library, device chrome, designer charter, clarifying questions, memory, user-level instructions, project-level instructions, design system, craft, skills, filename rules, plugin blocks, stage blocks, metadata, deck framework, media contract, design-system direction override…
They will fight. Three real conflicts:
| Conflict | Who should win | How you guarantee it |
|---|---|---|
| Discovery layer says “turn 1 must ask direction” vs a design system is already selected | Design system | Add ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE at the very tail |
| Discovery layer says “call TodoWrite” vs API mode has no tools | API mode | Pin API_MODE_OVERRIDE at the absolute top |
| Memory says “the user likes dark” vs this brand is light | Brand | Explicitly write “brand wins on conflict” in the memory-block preface |
10.2 Three suppression methods
Method 1: position. Most models are more sensitive to the start and end of a prompt. So:
- Pin at the very top: for overrides that “this whole later section must be overturned” (API mode, injection resistance)
- Pin at the very tail: for overrides that “must beat one specific earlier rule” (design-system direction, deck framework)
The JSDoc at contracts/src/prompts/system.ts:498-512 writes this reason in full:
Why it sits ABOVE
DISCOVERY_AND_PHILOSOPHY: that layer starts with "these override anything later in this prompt" and then mandates TodoWrite / Bash / Read / WebFetch on turns 2–3. In daemon mode those tools exist; in API mode they don't… Pinning the override at the absolute top is the cleanest way to beat the discovery layer's precedence without restructuring its rules.
Method 2: explicit arbitration sentences. Do not expect the model to infer who wins. Write it:
Treat them as preferences and context, NOT hard rules: when they collide with the active design system tokens, the brand wins; when they collide with the active skill's workflow, the skill wins.
Method 3: conditional gating. Rather than write a paragraph “if X then ignore the below,” do not put it in at assemble time.
Ask mode is the best example: when sessionMode === 'chat', do not assemble the discovery layer (~3000 tokens), direction library, device chrome, designer charter, deck framework, media contract, critique panel, design-system direction override. But keep memory, custom instructions, the active design system, extra skills, plugins, MCP tools, the clarifying-question surface.
Ask mode is light, not amnesiac. What you save is workflow; what you keep is context.
10.3 An in-progress refactor: slim vs classic
Open Design currently has two assemble paths coexisting:
| Variant | Approach | Status |
|---|---|---|
| classic | Layered stack: discovery layer + charter + tail overrides | in production |
| slim | Collapse the three blocks into one charter document | A/B validation |
The comment is frank: “the classic stack keeps the legacy layered composition until the A/B comparison signs off”.
This approach is worth copying: prompt-refactor risk is extremely high (changing one word can drop pass rate 20%), so do not replace in one shot—two stacks coexist + A/B.
slim also carries an optimization that only holds under a specific condition: turn the 6.7 KB direction library into “id+label index + od tools directions --id <id> pull on demand”—but only under the filesystem execution profile, because the text_artifact grade has no tools to dereference the index; “anything less tells them to bind palettes they cannot fetch”.
✅ This step’s acceptance: select a design system then open a new project → the questionnaire must not contain direction/theme-color questions; switch to Ask mode → system-prompt length should drop an order of magnitude.
Step 11 · Make it cheap: band by change frequency + cache-hit attribution
11.1 The problem
Your system prompt is 30–80 KB. The user chats 20 turns in one project. If every turn is a brand-new prefix, the money you burn can be optimized by an order of magnitude.
LLM prefix cache is prefix match: as long as the first N tokens are the same, you hit. So sort order decides cost.
11.2 Four bands by change frequency
flowchart LR
Z1["① globally static
designer charter · injection resistance
[shared by all sessions]"] --> Z2["② session-stable
mode override · locale
[unchanged inside one session]"]
Z2 --> Z3["③ project-stable
design system · skills · metadata · platform
[unchanged inside one project]"]
Z3 --> Z4["④ turn-variable
deck/media/platform signal-triggered blocks
[may flip every turn]"]
style Z1 fill:#e8f5e9
style Z2 fill:#f1f8e9
style Z3 fill:#fff9e6
style Z4 fill:#ffebee
Comment at daemon/src/prompts/system.ts:851-867:
slim (non-ask): the STATIC charter opens the document… so every conversation shares the same cacheable prefix; conversation-stable overrides (mode, locale) follow, project context after that, turn-variable blocks last.
11.3 The most delicate one: the stability of the trigger signal decides the block’s position
The same content block, depending on which signal triggered it, sits in a different band:
// metadata signal (fixed at project creation) → project-stable band
if (isSlimCore && metadataPlatformSignal) {
parts.push(PLATFORM_CONTRACTS_BLOCK, '\n\n---\n\n');
}
// conversation-text signal (may flip mid-session) → push to turn-variable suffix
else if (isSlimCore && (platformHintSignal ?? false)) {
slimTurnVariableParts.push(`\n\n---\n\n${PLATFORM_CONTRACTS_BLOCK}`);
}
The comment is clear:
The conversation-text signal is turn-variable (a mid-session "make it an iOS app" flips it on), so signal-only triggers defer the block to the turn-variable suffix… an early insert would break the cached prefix for every section after this line.
The same block, in the wrong place, destroys the cache of every later section.
11.4 Cache-hit attribution: when it breaks you must know which section
Optimize without observing and you will not know your cache hit rate is dropping. So attribute:
describeStablePromptCache({ isResuming, storedStablePromptHash, currentStableHash, … })
// →
if (!isResuming) → missReason: 'new-session'
if (storedHash === currentHash) → hit: true
if (storedHash === null) → missReason: 'missing-stored-hash'
else → missReason: 'stable-prompt-changed'
+ changedSections: per-section diff
One detail: changedSections is only computed on 'stable-prompt-changed'. The comment:
missing-stored-hashis a legacy/again-seeded row with no baseline to diff against, so naming sections there would report the whole map as "changed" and drown the signal we care about.
Reporting “everything changed” when there is no baseline would drown the real drift signal—when doing telemetry, be careful of this class of “technically correct, zero information” output.
✅ This step’s acceptance: chat 5 turns in the same project; from turn 2,
hit: true; mid-session switch design system →missReason: 'stable-prompt-changed'andchangedSectionsprecisely names the design-system section.
Step 12 · First gate: write “looks fake at a glance” as regex
12.1 A counter-intuitive claim
“This page looks AI-made at a glance”—sounds entirely subjective. Split it open, and a large fraction is enumerable concrete patterns:
| Pattern | How concrete |
|---|---|
| Default Tailwind indigo as accent | Exact 7 hexes: #6366f1 #4f46e5 #4338ca #3730a3 #8b5cf6 #7c3aed #a855f7 |
| Two-stop “trust gradient” | 13 blue-family hexes × 8 cyan-family hexes paired |
| Emoji as functional icons | 17: ✨ 🚀 🎯 ⚡ 🔥 💡 📈 🎨 🛡️ 🌟 💪 🎉 👋 🙌 ✅ ⭐ 🏆 |
| Rounded card + left colored border | Classic “AI dashboard tile” shape |
| Display in sans-serif | h1/h2/h3 font-family landing on Inter/Roboto/Arial/-apple-system/system-ui/SF Pro |
| Invented metrics | 10× faster 99.9% uptime zero-downtime 3× more productive |
| Filler copy | lorem ipsum feature one\|two\|three placeholder text sample content |
Enumerable → writable as regex → automatically checkable.
12.2 Sixteen rules
| Level | id | Check |
|---|---|---|
| P0 | purple-gradient |
a gradient contains one of 20 violet/indigo hexes, or the literals purple/violet |
| P0 | trust-gradient |
blue→cyan pairing |
| P0 | ai-default-indigo |
solid use of one of 7 default accent hexes |
| P0 | emoji-icon |
slop emoji in <h*> / <button> / <li> / class*="icon" |
| P0 | left-accent-card |
rounded + left color bar |
| P0 | sans-display |
headings in sans-serif |
| P0 | invented-metric |
invented numbers |
| P0 | filler-copy |
filler copy |
| P0 | scroll-into-view |
used Element.scrollIntoView() (across an iframe boundary it will drag the host page) |
| P0 | slide-theme-missing |
a deck .slide missing light/dark/hero theme class |
| P1 | all-caps-no-tracking |
uppercase but tracking <0.06em |
| P1 | external-image |
unsplash / placehold.co / picsum etc. external placeholder images |
| P1 | raw-hex |
naked hex >12 outside :root{} |
| P1 | accent-overuse |
var(--accent) in body >6 times |
| P1 | slide-rhythm |
3 consecutive slides of the same theme |
| P2 | missing-section-anchor |
<section> missing data-od-id / data-screen-label |
12.3 Where the real engineering is: the ALL CAPS tracking rule
Naïve implementation:
// ❌ will miss half
const m = /letter-spacing:\s*([\d.]+)em/.exec(body);
if (!m || parseFloat(m[1]) < 0.06) report();
The real implementation does four things:
flowchart TB
S1["① extractCssTokens(html)
collect --name: value per scope"] --> S2
S2["② buildResolvedThemes(scopes)
compose global theme scopes (:root, [data-theme=…])
into multiple theme sets"] --> S3
S3["③ resolveCssVars(body, tokens)
recursively resolve var(--x), max depth 4"] --> S4
S4["④ resolveFontSizePx(decls)
font-size in the same rule converted to px (root=16)"] --> J
J["Judge: is letter-spacing
under each theme, at that font size,
≥ 0.06em equivalent"]
Why convert font-size? letter-spacing: 1px is enough on 12px type (0.083em) and far from enough on 48px type (0.021em).
Why resolve variables? letter-spacing: var(--caps-tracking) must not be treated as “unset.”
Why multiple theme sets? Under the light theme the token is 0.08em; under the dark theme it may be overridden to 0.02em.
12.4 A character-class difference that caused a miss
The comment at lint-artifact.ts:328-341 is a classic textbook of regex engineering:
The body alternation is
[^{}]*(not[^}]*) so the regex matches only innermostselector { body }rules. With[^}]*, an outer@media (...) { .display { font-size: 48px; text-transform: uppercase; … } }matches as a single rule whose selector is the@media (...)wrapper… the same-rule font-size is lost, and the check falls back to the lenient inherited-size path that accepts 1px tracking on a 48px heading.
[^}]* → [^{}]*, one character of difference, decides whether an uppercase heading inside @media is missed.
12.5 Three layers of false-positive defense
False positives are more fatal than misses—once people are tired of the errors, nobody looks. Three defenses:
| Defense | Against |
|---|---|
| Strip HTML comments | teaching examples in comments (“paste a <section class="slide"> here”) would match structural regexes |
| Strip CSS comments | /* .eyebrow { text-transform: uppercase; } */ is not rendered by the browser, but a rule-shape regex would match |
| Distinguish token definition vs direct use | defining indigo as a token is legal; using it directly as accent is not |
12.6 Thresholds need a reason
// lint-artifact.ts:415-419
Allow up to ~12 raw hex values outside :root. Device chrome
(mobile-app frame: bezel gradient, side rails, status icons) has
legitimate hardware-specific values in the 8–10 range; raise the
threshold so seed templates pass without ceremony.
The number 12 is reverse-engineered from “a phone-bezel seed template legitimately uses 8–10,” not a guess. Every threshold should be able to name the real scene it was reverse-engineered from, otherwise in six months nobody dares touch it.
12.7 Feedback loop: an error to the agent must carry a fix action
<artifact-lint>
The artifact you just produced has the following anti-slop / design-token issues.
2 P0 (must fix), 1 P1 (should fix), 0 P2 (nice to have).
Re-emit a corrected `<artifact>` in your next turn — do not write a separate
explanation; the user has the previous version already.
**[P0] ai-default-indigo** — Found #6366f1 used as a solid accent.
Fix: Use var(--accent) from the active design system.
Snippet: `background: #6366f1`
</artifact-lint>
Three points:
1. Sort by severity, P0 first
2. Explicitly require a corrected re-emit, do not write an explanation (“the user already has the previous version”)
3. Every item carries fix—give only message and the model will guess; guess wrong and you spend another turn
12.8 An honest boundary: do not hard-block
craft/README.md:68: “Artifact persistence is not currently hard-blocked on P0 hits.”
A P0 hit does not prevent writing to disk. That is correct—hard-block would turn “the model loops and cannot fix” into “the user gets nothing.” The gate’s job is to push improvement, not to prevent delivery.
✅ This step’s acceptance: hand-write HTML with a purple gradient + 🚀 icon +
lorem ipsumand feed the linter → should report 3 P0s; give it an uppercase heading usingletter-spacing: var(--tracking)with--tracking: 0.08em→ must not false-positive.
Step 13 · Second gate: a five-panelist critique theater
13.1 The half the linter cannot catch
Regex can catch “used indigo”; it cannot catch “this hierarchy is a mess.” The second gate uses a model to critique a model.
Five panelists:
| Role | What they score | Weight |
|---|---|---|
| Designer | layout, composition, hierarchy | 0.0 |
| Critic | whether it actually meets the brief; contrast, weight, readability | 0.4 |
| Brand | token compliance, tone, brand-color use | 0.2 |
| Accessibility | WCAG, focus rings, semantic structure, alt text | 0.2 |
| Copy | tone, brevity, error-copy quality | 0.2 |
Designer weight 0 is deliberate:
Designer is weighted at zero in v1 because their dimensions are aesthetic preferences rather than ship gates. The slot exists so the Designer's qualitative notes still travel into the transcript, and a future config release can bump the weight without changing the schema.
Keep the seat, zero the weight—qualitative notes enter the record, but subjective taste does not block ship. That is a mature product decision.
13.2 The most important implementation decision: one session, not five processes
flowchart TB
subgraph BAD["❌ Instinct"]
B1["open one process/session per panelist"]
B1 --> B2["five contexts that do not know each other"]
B2 --> B3["critique results contradict themselves"]
B1 --> B4["auth/env/logs must be copied five times"]
end
subgraph GOOD["✅ Correct"]
G1["five panelists = five turns of the same CLI session"]
G1 --> G2["separated by <PANELIST role='…'> tags"]
G2 --> G3["parsed into panelist_* events"]
G1 --> G4["run contract identical to ordinary generation
same auth, same env, same logs"]
end
Original (docs/critique-theater.md:32-35):
All five panelists are turns in the same conversation, which keeps the model context coherent and prevents the "panelist disagrees with itself across processes" failure mode.
13.3 Convergence loop
composite = designer×0.0 + critic×0.4 + brand×0.2 + a11y×0.2 + copy×0.2
threshold = 8.0 / 10
maxRounds = 3
perRoundTimeoutMs = 90_000 · totalTimeoutMs = 240_000
fallbackPolicy = 'ship_best' // or 'ship_last' / 'fail'
Meet threshold → ship; miss → send a round summary → agent edits → next round; three rounds without convergence take fallbackPolicy (default: the round with the highest composite).
Five settlement states: Shipped / Below threshold / Timed out / Interrupted / Degraded.
Interrupted copy must be written separately:
The
interruptedchip uses a distinct copy ("Interrupted at round N, best composite X.X") so the user is not told the run shipped when it did not.
Do not let “was interrupted” look like “shipped.”
13.4 Four-level switch: who can turn it on, who can turn it off
| Priority | Layer | Notes |
|---|---|---|
| 1 (highest) | skill-level od.critique.policy |
required / opt-in / opt-out |
| 2 | project-level override | localStorage (in-session UI) + read-merge-write on the project |
| 3 | environment variable | advanced users / CI |
| 4 (lowest) | rollout-stage default | M0/M1 off, M2 per skill, M3 all on |
Layer 2 has a data-loss defense:
GET the current project, merge into the existing metadata blob, PATCH the merged object so other metadata fields survive. If the prefetch GET fails the setter skips the PATCH entirely instead of stomping the row.
The failure branch of read-merge-write must be “do not write,” not “write defaults.”
Rule of thumb for skill authors: skills that produce deterministic artifacts (export PDF) → opt-out; skills that generate a brand-new design (landing page, poster) → required.
13.5 Consistency discipline across 25 CLIs
Your critique protocol requires the agent to emit a <CRITIQUE> block. 25 CLIs have different models and different compliance. So you need a degrade mechanism and a conformance bar:
| Degrade reason | Cause |
|---|---|
malformed_block |
the parser does not recognize this block |
oversize_block |
over 256 KB (the model ran away) |
adapter_unsupported |
this adapter is marked degraded, 24h TTL |
protocol_version_mismatch |
adapter protocol version is old |
missing_artifact |
run ended with no artifact—“Almost always a prompt bug” |
Conformance bar:
The conformance harness runs every adapter prerelease against 10 brief templates. If an adapter drops under the 90% shipped or 95% clean-parse thresholds for two consecutive cycles, it gets marked
critique:degradedfor 24h. The mark auto-clears on the next clean cycle.
Condition for a global switch:
globally during M3 after ≥ 90% of production adapters maintain conformance for 14 consecutive days.
A feature that depends on 25 third-party CLIs, using “14 consecutive days ≥90% conformance” as the global switch—this is the correct engineering posture for “uncontrollable third parties.”
13.6 Replayable
Every run writes a structured .ndjson (optional gz). The Replay button hangs a read-only theater, supporting Instant / Live / {intervalMs: N} / Paused (after pause, continue from the cursor, do not re-flush already-sent events), J/K jump per round, Esc to exit.
Why replay: critique is a multi-round process; after watching, the user wants to see again “why it gave me 6.2.” Without replay, that information is gone.
✅ This step’s acceptance: deliberately give a low-quality artifact → should run all 3 rounds and the state is
Below threshold; press Esc mid-way → badge copy should beInterrupted at round N, best composite X.X.
Step 14 · Make it remember you: a dual memory loop of three cards
14.1 Three iron laws of memory
Iron law 1: memory is preference, not a hard rule. The preface must explicitly arbitrate:
Treat them as preferences and context, NOT hard rules: when they collide with the active design system tokens, the brand wins; when they collide with the active skill's workflow, the skill wins. They are still authoritative for tone, voice, terminology … never re-ask the user about something already captured here.
Without this, the model will let “the user last said they like dark” override “this brand is light.”
Iron law 2: memory changes “what you know,” not “what you skip.”
Expanding intent this way changes only WHAT you know going in; it never shortcuts the standard build flow — you still plan with TodoWrite and still run the anti-slop / brand self-check on every artifact-producing turn.
Iron law 3: the model may not claim it remembered unless the same reply contains that card.
14.2 Three <od-card>s
flowchart TB
IN["user short request"] --> MEM{"is memory enough
to expand into a brief?"}
MEM -->|enough & rewrite=ON| C1["① task-brief card
replaces the turn-1 questionnaire"]
MEM -->|not enough| FORM["go RULE 1 questionnaire"]
C1 --> BUILD["TodoWrite + build
[not skippable]"]
FORM --> BUILD
BUILD --> SLOP["anti-AI-slop / brand self-check
[not skippable]"]
SLOP -->|verify=ON & verified rules exist| C2["② verify-scorecard card
host programmatically checks it exists"]
SLOP --> HAND["delivery wrap-up"]
C2 --> HAND
HAND --> FB{"does the user's correction
imply a reusable rule?"}
FB -->|yes| C3["③ rule-proposal card
Keep / Edit / Discard"]
C3 -->|click Keep| STORE[("verified rule store")]
STORE -.->|inject next time| MEM
① task-brief (PRE loop)
When a short request is expanded by memory into a full brief, emit a collapsed card at the very start of the reply:
<od-card type="task-brief">
{ "summary": "<one-line restatement of the expanded intent>",
"fields": [ {"label":"Audience","value":"…"},
{"label":"Deliverable","value":"…"},
{"label":"Done means","value":"…"} ] }
</od-card>
Constraints: at most one per turn; skip if the request is already explicit or trivial (greeting, yes/no, a small edit); never output the brief as prose, only as a card.
The most important sentence:
The task-brief card REPLACES the turn-1 discovery question-form when memory already makes the intent clear — it does NOT replace the rest of the build flow. … Skipping the discovery form when intent is already understood is correct; skipping TodoWrite or the anti-slop gate is not.
② verify-scorecard (POST loop) — programmatic enforcement appears here
After producing/editing an artifact, check “verified rules” one by one, fix every failure, then emit the scorecard:
<od-card type="verify-scorecard">
{ "status": "pass|partial|fail",
"summary": "5/6 checks passed · 1 auto-fixed",
"rows": [ {"rule":"<check>","status":"pass|fail|fixed","note":"<what was wrong/what was fixed>"} ] }
</od-card>
The daemon programmatically checks this scorecard after your turn — a missing scorecard or a rule left uncovered on an artifact turn is recorded as an enforcement failure.
And it specifies wrap-up order: (1) finish anti-AI-slop/brand self-check and fix in place → (2) emit the scorecard → (3) ordinary delivery wrap-up.
Plus a behavioral preference: “Prefer fixing silently over asking. Leave a row as fail only when fixing it needs a decision you genuinely cannot make.”
③ rule-proposal — the most restrained card
When the user’s correction implies a reusable, checkable rule, propose rather than silently save:
<od-card type="rule-proposal">
{ "name":"<short name>", "description":"<one line>", "assertion":"<what must hold>",
"check":"<how to verify>", "rationale":"<why this inference>" }
</od-card>
Propose at most one rule per turn, and only when confident it generalizes beyond the current artifact. Do not claim in prose that a rule was recorded, saved, noted, added to memory, or will be remembered unless this same response includes the rule-proposal card for that rule; the rule becomes saved only after the user clicks Keep.
The last half-sentence treats a specific model-lying behavior: models love to say “ok, I remembered,” but actually remembered nothing. The fix is to bind the assertion “I remembered” to the card’s existence—no card, you may not say it.
14.3 Why this design is worth copying
Compare memory schemes in other projects of this series:
| Project | Memory form | User visibility |
|---|---|---|
| openworker | explicit SQLite facts | visible and editable in a settings panel |
| Raven | EverOS dual track (working memory + long-term) | partly visible |
| nanobot | Dream night reflection | result visible, process not |
| Open Design | three cards + verified rule store | every step is a visible, editable, rejectable UI element |
The core difference: Open Design turns “the model’s internal state” into “UI the user can see, edit, and refuse.” Write requires the user to click confirm; check has programmatic enforcement; every rule traces to “which correction produced it.”
✅ This step’s acceptance: correct the same thing three times in a row → the third time a rule-proposal card should appear; after clicking Keep, the next generation should show a verify-scorecard that includes this rule.
Step 15 · Make it extensible: four planes + atoms + a closed until
15.1 Minimal plugin shape
my-plugin/
├── open-design.json ← required: marketplace metadata + inputs + pipeline + capabilities
├── SKILL.md ← required for agent-skill / scenario types; optional for others
├── README.md ← optional
├── preview/ ← optional: index.html / poster.png (strongly recommended for visual kinds)
└── examples/ ← optional
Core fields: specVersion · name (stable ID) · version (semver) · od.kind (skill/scenario/atom/bundle) · od.taskKind (new-generation/figma-migration/code-migration/tune-collab) · od.mode · od.capabilities[] · od.inputs[].
An important default: od.capabilities[] must declare a minimum set—restricted installs default to only prompt:inject.
15.2 Atoms: capability units the host exposes to plugins
Plugins do not own implementations; they only cite by id. The host is responsible for resolving each atom into: a system-prompt fragment + tool gating + a GenUI surface declaration.
13 built-in atoms (a few to show intent):
| id | Notes |
|---|---|
discovery-question-form |
turn-1 questionnaire (turns step 9’s RULE 1 into a citable unit) |
todo-write |
TodoWrite-driven plan |
research-search |
Tavily-backed shallow research |
critique-theater |
five-dimension critique, emits a critique.score signal that drives convergence |
design-extract / figma-extract / token-map |
migration-scene trio |
build-test |
run build/typecheck/tests, produce build.passing / tests.passing |
handoff |
push the artifact downstream (cli / cloud / desktop) |
15.3 Pipeline: five steps + one audit line
flowchart TB
M["plugin manifest
od.pipeline.stages[*].atoms[]"] -->|① resolve| PS["PipelineStage[]"]
PS -->|② before run| B["resolve built-in atom instruction bodies
render as ## Active stage prompt blocks"]
B --> R["③ walk stages at runtime"]
R --> E1["emit pipeline_stage_started"]
E1 --> W["④ ask the worker registry for
host-observable signals"]
W --> A["⑤ write one audit line to run_devloop_iterations"]
A --> E2["emit pipeline_stage_completed + signals"]
E2 --> R
One place that must be honest:
Atoms whose work happens inside the selected agent CLI may use the registry's permissive compatibility signals because the daemon has no independent observation for that tool action.
For atoms like file-write that actually happen inside the child, you cannot observe the real result; you can only give a permissive signal. Do not pretend you can observe everything—saying so in the docs is better than inventing a fake signal.
15.4 The until vocabulary must be closed
Stage convergence conditions use a closed signal vocabulary:
| Signal | Who emits it |
|---|---|
critique.score |
critique-theater |
iterations |
built-in counter |
user.confirmed |
when a confirmation GenUI surface is parsed |
preview.ok |
live-artifact preview pipeline |
build.passing / tests.passing |
build-test flow |
The evaluator is deliberately closed and is not arbitrary JavaScript. Unknown signals fail parsing and
od plugin doctorreports them.
Why this is the most important sentence of the step: if a plugin can write arbitrary JS as a convergence condition, you must sandbox it, audit it, worry about infinite loops, worry about it reading env vars. A closed vocabulary limits “what a plugin can express” to semantics the host can guarantee—the cost is expressiveness; what you buy is an openly installable plugin marketplace.
Any extension point opened to third parties must ask this once: am I putting data here, or code?
15.5 Atom promotion path
Do not make a new capability a built-in atom on day one. The path is:
① first implement as an out-of-tree plugin
↓ after SKILL.md / MCP tools / pipeline shape stabilize
② add a built-in atom + append one line to FIRST_PARTY_ATOMS
+ register a worker only when there is a real observable signal
↓ the same PR
③ update docs and spec tables
↓
④ reachable via pipeline cite / GET /api/atoms / od atoms list / od plugin doctor
✅ This step’s acceptance: write a plugin;
until: "critique.score >= 8"can run; change it tountil: "myCustomThing == true"→od plugin doctorshould report an unknown signal.
Step 16 · Fit the boundary: you gave up the permission gate, so you must complete the perimeter
16.1 Be honest first: you did give up the permission gate
Your buildArgs contain these:
| CLI | What you feed it |
|---|---|
| Claude | --permission-mode bypassPermissions |
| Cursor | --force + capability-gated --trust |
| Devin | --permission-mode dangerous --respect-workspace-trust false |
| Qoder / Trae | --yolo |
| Copilot | --allow-all-tools |
| DeepSeek | --auto |
| Amp | --dangerously-allow-all |
Why must it be this way? Because you run them in an environment with no TTY. An interactive approve prompt would hang the run immediately—the user waits in the browser; the child waits for a y that will never come.
So write this into the docs; do not hide it:
The effective project cwd is an execution root, not a uniform Open Design sandbox, and external-directory flags can widen a CLI's reach. … users must treat these runs as trusted agent execution with the authority shown by the selected definition.
Having given up the execution-layer gate, you must spend the entire budget on the boundary. Five items below.
16.2 Boundary 1: loopback by default
- The daemon binds
127.0.0.1by default - LAN exposure requires both
OD_BIND_HOSTandOD_ALLOWED_ORIGINS(neither alone suffices) - Connector credentials and preview routes stay loopback-only no matter what—even a public deploy does not open them
16.3 Boundary 2: SSRF—block intranet by default; opt-out is extremely strict
You have a BYOK proxy; the user can fill any baseUrl. This is a textbook SSRF surface.
Default: block provider base URLs that resolve to private/internal addresses—RFC1918, link-local, CGNAT, cloud-metadata IPs (the 169.254.169.254 class, which can steal IAM credentials).
But real users do have intranet gateways (LiteLLM inside a VPN, Ollama). So there must be an opt-out, and it must be strict:
| Property | Rule |
|---|---|
| Strict opt-in | empty by default |
| Exact host match | no subdomain/substring match |
| Format-tolerant | accept host:port or a full URL, reduce to hostname; IPv6 must be [fd00::1] |
| Scope-limited | only applies to provider endpoints you yourself configured |
| Does not relax downstream | deliberately does not relax download URLs returned in upstream responses |
| Bad entries discarded | malformed entries, CIDR notation (unsupported) are discarded with a warning, not silently trusted |
And you must name residual risk:
Allowlisting a hostname trusts whatever it resolves to; allowlist the resolved IP instead if you want the DNS-resolved address re-checked.
Allowlisting a hostname = trusting the DNS result (DNS-rebinding risk). Naming residual risk is worth far more than saying “we are safe.”
16.4 Boundary 3: HMAC one-shot tokens for desktop folder import
The user wants to import an arbitrary local folder. This capability is dangerous—can a malicious script in the renderer construct a request that makes the daemon read /etc/?
sequenceDiagram
participant U as User
participant M as Electron main (trusted)
participant R as Renderer (sandboxed)
participant D as Daemon
U->>M: click "Import folder"
M->>U: native folder picker
U->>M: select /Users/me/work/site
M->>M: mint a short-lived, one-shot HMAC token
M->>R: token + path
R->>D: POST /api/import/folder (with token)
D->>D: verify HMAC + normalize path
refuse imports that land inside hosted storage
D->>D: stamp a server-controlled "trusted picker" mark
Note over D: every later file access does safe path resolve against that external root
The key sentence: that “trusted picker” mark is server-controlled; an ordinary project create/update request cannot forge it. The renderer never gets the HMAC key.
16.5 Boundary 4: preview must be a sandboxed iframe
- Sandboxed iframe, no host same-origin access
- Each face only opt-in to the sandbox features it needs (download, popups)
- When switching URL/srcDoc render modes, keep both frames mounted to avoid reload flicker
- Message handlers validate the sending iframe
- Signals that must come from the active frame are checked against the active window again
The last two defend: when a page has multiple iframes, a background frame impersonating the active frame and sending messages.
16.6 Boundary 5: skill staging must be a copy, not a symlink
This is the most worth-copying one, because it was a vulnerability caught in code review.
Wrong version (PR #435 round 1): make .od-skills/ a directory symlink pointing at the repo skills/ tree.
Review comment:
write-amplification vulnerability: agents have write access to their cwd, and a
Write/Edit/Bashcall against.od-skills/<id>/SKILL.mdresolves through the symlink and mutates the shipped resource itself.
One Edit can break the skill source file shared by all projects.
Correct version: a real copy per project.
export const SKILLS_CWD_ALIAS = '.od-skills';
export function skillCwdAliasSegment(dir: string): string {
const folder = path.basename(dir) || 'skill';
const normalizedDir = path.resolve(dir).replaceAll('\\', '/');
const digest = createHash('sha256').update(normalizedDir).digest('hex').slice(0, 10);
return `${folder}-${digest}`; // saas-landing-3f2a91b0c4
}
Four companion details:
| Detail | Why |
|---|---|
Stage only the active skill, not the whole skills/ |
a single skill is 1–3 MB; on APFS/btrfs/ReFS fs.cp is CoW; steady-state cost is a few syscalls |
dereference: true |
the copy is fully self-contained; nothing inside it can write back to a real file outside the project |
stat() rather than lstat() the source root |
some environments put skills/ itself behind a symlink (content-addressed mounts); follow it |
| Path hash as suffix | user root and built-in root may have same-named skills (shadowing); when both are selected, directory names collide |
| Cross-filesystem streaming-copy fallback | copy_file_range(2) is refused across filesystems (EXDEV; container image layers copied onto ZFS/overlay is EPERM); Node does not auto-degrade |
| Give the prompt two paths | cwd-relative staged path (primary) + absolute source path (fallback); if staging fails the agent can still work |
16.7 Boundary 6: a triple guard for Windows command-line length
If you must support a CLI that only eats argv (e.g. clap declares prompt: String required, no - stdin sentinel), you need three guards:
| Guard | When | Check |
|---|---|---|
checkPromptArgvBudget |
before bin resolve (fast) | raw prompt byte count vs maxPromptArgBytes (e.g. 30 000) |
checkWindowsCmdShimCommandLineBudget |
after buildArgs |
when a .cmd/.bat shim is resolved, recompute cmd.exe /d /s /c "<inner>" with the same per-arg quote-doubling rules as the platform layer |
checkWindowsDirectExeCommandLineBudget |
after buildArgs |
when a non-shim .exe is resolved, recompute with libuv quote_cmd_arg rules (each " becomes \"; backslashes adjacent to quotes double) |
The two Windows guards are mutually exclusive for a given resolve. Together they catch: raw bytes did not overflow, but a quote-dense prompt (code blocks, JSON-shaped skill seeds) expands past CreateProcess’s 32 767 character ceiling.
All three emit the same actionable error: tell the user “reduce skill/design-system context, shorten the conversation, or switch to an adapter that supports stdin.” And all three have unit tests (over-long + short-prompt branches, quote-dense regressions on both Windows paths, mutual-exclusion checks), “so the guards can't silently regress”.
✅ This step’s acceptance: break
.od-skills/xxx/SKILL.md→ sourceskills/should be untouched; fillhttp://169.254.169.254/in BYOK → should be refused with a clear reason; on Windows, run a 40 KB quote-dense prompt on an argv-only adapter → should emitAGENT_PROMPT_TOO_LARGEbefore spawn, not an unreadableENAMETOOLONG.
Step 17 · Ship it as a product: desktop shell + sidecar + MCP server + export
17.1 Three run shapes, one codebase
| Shape | Entry | Character |
|---|---|---|
| Source development | pnpm tools-dev run web |
dynamically allocated ports, daemon + web sidecar |
| Packaged desktop / headless | Electron / headless launcher | resolve channel/namespace-scoped runtime and data identity, then pull up the daemon |
| Container / daemon serving directly | docker compose up -d |
the same daemon serves the static export + /api/* directly |
An important architectural discipline:
Ports are transport details; they do not define process identity, namespaces, or daemon data roots.
In packaged-desktop mode Electron does not assume a port—it asks the web’s real URL through sidecar IPC.
17.2 Data-root contract: one-place documentation discipline
This is the most worth-copying meta-rule in the whole project:
This document intentionally gives no concrete daemon data path. The root
AGENTS.mdsection Daemon data directory contract is the only path authority.
The README repeats the ban (“This README MUST NOT restate it”).
Why it deserves a standalone rule: multiple docs each hard-coding a path is the classic rot source of every local-first app. You change the implementation; six of eight markdowns still write the old path; users following the docs cannot find their data. Downgrade the path to a single authority; other docs may only cite, never restate.
Implementation: at start, resolve OD_DATA_DIR once into RUNTIME_DATA_DIR; afterwards SQLite, project workspaces, artifacts, user registry, credentials, automation state all derive from this root. The only exception is folder import (uses the user-picked external root, validated + bounded, not copied).
17.3 Dual-track rule: a capability must appear in both UI and CLI
User-facing capabilities must be reachable through both Web/API routes and
odCLI subcommands. When adding a user-facing capability, close the loop in one change: contract type, daemon route, web surface if applicable, and CLI command with--jsonplus--prompt-file <path|->for long prompts where relevant.
Why force it? Because the CLI is how an external agent consumes your product. If a capability exists only in the UI, then Claude Code coming in via MCP cannot use it—your “consumable by any agent” selling point has a hole.
And note --json: every command must support it, so it can | jq | xargs into automation.
17.4 Make yourself an MCP server
This closes the loop of the “agent-native” positioning: you not only call other people’s agents, you must be called by other people’s agents.
od mcp install <agent> # one line into 16+ CLI configs
# then, in that agent:
od project list --json
od files list <project-id> --json
od files read <project-id> <relative-path>
od plugin list --json
od skills list --json
Why MCP rather than exporting a zip: “Exporting and re-attaching a zip every iteration breaks flow. MCP exposes the design source directly — the agent always sees the live file, not a stale export.”
Security model: read-only by default, bound to 127.0.0.1, SSRF blocked at the proxy edge.
A real pit: on macOS / WSL2, /usr/bin/od is the system’s octal-dump tool and will shadow your od on PATH. Three responses:
1. Desktop App Settings → MCP server gives a snippet using an absolute path
2. install.sh is a thin wrapper around od mcp install; its reason to exist is “a hosted URL returns shell rather than a landing-page HTML fallback, and fails fast when the shell resolves a non-Open-Design od”
3. Three reminders in the docs
Lesson: before picking a command name, which <name> against common system tools.
17.5 Export matrix
| Format | Implementation |
|---|---|
| HTML | single file, all resources inlined |
| browser print, deck-aware | |
| PPTX | an agent-driven skill (plus a pptx-html-fidelity-audit skill for fidelity audit) |
| ZIP | archive |
| Markdown | — |
| MP4 | HyperFrames (HTML+CSS+GSAP → headless Chrome + FFmpeg) |
Note PPTX goes through a skill, not a library—because HTML→PPTX fidelity is a judgment problem, not a conversion problem.
17.6 Do not do cross-agent automatic fallback
A counter-intuitive but correct decision:
Open Design does not implement an ordered cross-agent fallback chain. A chat request explicitly names its agent, and a crash, auth failure, timeout, or invalid invocation remains a failure for that run. … the daemon does not silently — or through a dedicated one-click fallback action — move the request to another detected CLI.
Why? Auto-switching agents would make billing, auth, and output style all quietly change; the user would not know who made the artifact they just got.
Only two narrow rules:
- Pre-run default (when agentId is omitted): use the configured agent if available, otherwise the first available. “This chooses an agent before a run starts; it is not failure recovery.”
- Stale-session recovery: clear the same agent’s stale session, re-seed with the full transcript. “That recovery never changes agent families.”
17.7 Mock agent: regression tests that do not burn quota
Maintaining 25 adapters, truly running 25 CLIs every time you change a parser is impossible. So you need:
mocks/mock-agent.mjs— fake CLImocks/recordings/— recorded real streamsmocks/golden/— expected output
The rule: “For agent-stream/parser changes, replay a mock CLI trace from mocks/ when practical instead of burning provider budget.”
✅ This step’s acceptance:
od project list --json | jqcan run; stop the daemon; the desktop App should give a clear error rather than a white screen; change a parser → mock replay can finish the regression in 5 seconds.
🎬 Full replay: what that one sentence actually ran
The user types at Home: “Make us a SaaS product landing page, using our company’s brand.” Then selects design system linear-app, clicks Run.
sequenceDiagram
autonumber
participant U as User
participant W as Web
participant D as Host Daemon
participant C as claude child
participant F as Project files
U->>W: type brief + pick linear-app + Run
W->>D: POST /api/projects (kind=prototype, designSystemId=linear-app)
D->>D: allocate hosted project workspace (derived from RUNTIME_DATA_DIR)
W->>D: POST /api/chat (SSE)
Note over D: [assemble]
D->>D: probe agent (concurrent, already warm) → claude available
D->>D: stage active skill to .od-skills/saas-landing-3f2a91b0c4/ (real copy)
D->>D: assemble system prompt (20+ layers, banded by change frequency)
Note right of D: ① injection resistance
② designer charter
③ discovery layer (3k tokens)
④ linear-app USAGE→DESIGN.md→tokens.css→component inventory
⑤ craft: typography+color+anti-ai-slop
⑥ saas-landing SKILL.md
⑦ metadata block
⑧ design-system direction override (suppresses direction questions in ③)
D->>D: compute stablePromptHash → new session, miss
Note over D: [launch]
D->>D: resolveAgentLaunch → real executable path
D->>D: buildArgs → ['-p','--input-format','stream-json',
'--output-format','stream-json','--verbose',
'--include-partial-messages','--session-id',,
'--permission-mode','bypassPermissions']
D->>C: spawn(cwd=project workspace), prompt via stdin (JSONL user message), stdin stays open
Note over C: [turn 1 · RULE 1]
C-->>D: text-delta: "Got it — a SaaS landing in Linear's design language. A few things:"
C-->>D: text-delta: …
D-->>W: SSE event stream
W->>U: render questionnaire card (4 questions, all prefilled; **no** direction/theme-color questions)
Note over C: stop. no file reads, no TodoWrite.
U->>W: submit as-is (no edits)
W->>D: POST /api/chat ("[form answers — discovery] …")
D->>C: write a new user message on the same stdin (do not restart the process)
Note over C: [turn 2 · RULE 2 branch B]
C-->>D: active design system present → do not ask direction, go straight to RULE 3
Note over C: [turn 3 · RULE 3]
C-->>D: tool-call TodoWrite (9-step plan)
D-->>W: render Todos card
C->>F: Read .od-skills/saas-landing-…/assets/template.html
C->>F: Read references/layouts.md, checklist.md
C-->>D: mark step1 completed, step2 in_progress
C->>F: Write saas-landing.html (bind linear-app :root tokens)
D-->>W: file-write event → file workspace appears → sandboxed iframe preview
C-->>D: steps 4–6 completed one by one
Note over D: [gate 1]
D->>D: lintArtifact(saas-landing.html)
D->>D: hit 1 P1: accent-overuse (var(--accent) used 9 times)
D->>C: system reminder (with fix + snippet)
C->>F: Edit saas-landing.html (downgrade 7 sites to var(--fg))
Note over C: [self-check]
C-->>D: step7 checklist.md, all P0 pass
C-->>D: step8 five-dimension self-score: specificity 2/5 (filler copy) → rework
C->>F: Edit (replace [REPLACE] with real copy from the brief)
C-->>D: re-score: all ≥3/5
Note over D: [gate 2 · if enabled]
D->>C: Design Jury round 1 (5 turns of the same session)
C-->>D: 8.5 …
D->>D: composite = 8.3 ≥ 8.0 → Shipped at round 1
Note over C: [deliver · filesystem profile]
C-->>D: ordinary summary: "wrote saas-landing.html — hero / three capability blocks /
social proof / pricing / CTA, all bound to Linear tokens."
Note right of C: ❌ do not emit an source block (filesystem profile forbids it)
D-->>W: done event
W->>U: preview + "Export PDF / HTML / ZIP" buttons
U->>W: export PDF
W->>D: browser print path → PDF
On this chain, whose code is whose?
| Link | Whose code |
|---|---|
| Probe, launch, argv construction | yours |
| Prompt assembly (20+ layers) | yours (and this is your most important product) |
| Skill staging | yours |
| Understanding the brief, deciding what to ask, writing HTML, editing | claude’s |
| Stream parse → unified events | yours |
| Anti-AI-slop linter | yours |
| Critique-theater orchestration | yours (scoring is claude’s) |
| Preview, export | yours |
Reasoning is all someone else’s. The product is all yours.
Appendix A · Acceptance assertions (how to verify each step)
| Step | Assertion |
|---|---|
| 1 | Can say in one sentence “why we do not write the main loop,” and list three new problems the decision creates |
| 2 | Adding a CLI of a known wire format = one new file + one registry line, zero engine change |
| 2 | A user profile colliding with a built-in id → the daemon will not start (load-time throw), not a silent overwrite |
| 3 | Move claude off PATH → only it greys out; the other 25 are unaffected |
| 3 | nvm-installed shim CLI: probe result and whether it actually runs agree |
| 4 | A 60 KB prompt runs to completion on macOS + Windows |
| 4 | Stream does not cut mid-tool-call (do not close stdin on tool_use) |
| 5 | Feed “fake artifact inside a fence + real artifact + unclosed bad artifact” → only the real one lands on disk |
| 6 | plain profile and filesystem profile run the same brief; artifacts land in the same place, with the same inventory |
| 7 | Switch design system → next generation’s :root swaps as a whole |
| 7 | Break one value in tokens.css → guard reports derived-file inconsistency |
| 8 | Skill writes requires: [typograpy] → lint:craft errors and points at the manifest path; runtime only skips |
| 9 | New project, vague brief → questionnaire card within 2 seconds, every question prefilled; submit as-is produces a reasonable artifact |
| 10 | Design system selected → questionnaire does not contain direction/theme-color questions |
| 10 | Switch to Ask mode → system-prompt length drops an order of magnitude |
| 11 | Same project, 5 turns; from turn 2, hit: true |
| 11 | Mid-session switch design system → missReason: 'stable-prompt-changed' and changedSections precisely names the section |
| 12 | Purple gradient + 🚀 + lorem ipsum → report 3 P0s |
| 12 | letter-spacing: var(--tracking) and --tracking: 0.08em → no false positive |
| 13 | Low-quality artifact → run all 3 rounds and state is Below threshold |
| 13 | Esc mid-way → badge is Interrupted at round N, best composite X.X, not Shipped |
| 14 | Correct the same thing three times → a rule-proposal card appears |
| 14 | After Keep, next generation shows a verify-scorecard that includes this rule |
| 15 | until: "critique.score >= 8" can run; unknown signal → od plugin doctor errors |
| 16 | Break .od-skills/xxx/SKILL.md → source skills/ untouched |
| 16 | BYOK fill http://169.254.169.254/ → refuse and explain |
| 16 | Windows + quote-dense 40 KB prompt + argv-only adapter → AGENT_PROMPT_TOO_LARGE before spawn |
| 17 | od project list --json \| jq can run |
| 17 | Change a parser → mock replay finishes the regression in 5 seconds |
Appendix B · Twelve easiest ways to crash
① Probe and execution used different path resolvers. Symptom: the UI says “not installed,” but running by hand is fine (especially nvm/fnm/mise + a GUI-launched App). Fix: the first thing the probe entry does is call the exact same path-resolution function spawn uses.
② The prompt went on argv.
Symptom: Linux spawn E2BIG, Windows spawn ENAMETOOLONG, and it is intermittent (prompt length depends on which design system was selected).
Fix: if stdin is available, use stdin; if it must be argv, put on the triple guard (including Windows quote-expansion recompute).
③ Closed stdin on tool_use.
Symptom: the turn mysteriously ends mid-tool-call.
Fix: close only after a clean turn_end / usage.
④ Double context causes a questionnaire loop.
Symptom: turn 2 pops the discovery questionnaire again; it looks like a stuck loop.
Root cause: the CLI already has session memory, and you also spliced the rendered transcript into the user message; it sees the raw <question-form> it emitted last turn and pattern-matches a repeat.
Fix: add an opt-out for this class of adapter, skip transcript injection.
⑤ <artifact> extraction ate teaching examples inside code fences.
Symptom: a pile of strange .html files appear in the project.
Fix: first compute Markdown-fence + inline-backtick skip ranges; and keep it consistent with the browser-side parser.
⑥ Override block in the wrong place.
Symptom: in API mode the model emits <todo-list> pseudo-markup; or a design system is selected and it still asks theme color.
Fix: know which section each override must beat—pin global overrides at the very top; pin specific-rule overrides at the very tail.
⑦ A turn-variable block was inserted into the cache prefix. Symptom: cache hit rate oscillates; the bill is unstable. Root cause: a block triggered by conversation text was inserted into the project-stable band; one mid-session sentence invalidated the cache of every later section. Fix: the stability of the trigger signal decides the block’s position, not the importance of the content.
⑧ Linter false positives drown real issues. Symptom: the agent spends every turn fixing linter reports the user cannot see. Fix: strip HTML comments, strip CSS comments, distinguish token definition vs direct use; every threshold must name the real scene it was reverse-engineered from.
⑨ Five panelists opened five processes. Symptom: critique results contradict themselves; auth/env/logs must be maintained five times. Fix: five panelists = five turns of the same session, separated by tags.
⑩ Skill staging used a symlink.
Symptom: an agent in one project broke SKILL.md; all projects broke together.
Fix: per-project real copy + dereference: true + path-hash suffix + cross-filesystem streaming fallback.
⑪ Let plugins write arbitrary JS as a convergence condition.
Symptom: as soon as the marketplace opens, you get infinite loops, env-var reads, plugins that never time out.
Fix: until uses a closed vocabulary; unknown signals fail parse and doctor reports them.
⑫ Memory writes without user confirmation. Symptom: the model says “I remembered” but remembered nothing; or it remembered a pile of “rules” the user never agreed to. Fix: three cards—the proposal card requires Keep before it lands; and forbid the model from claiming it remembered when there is no card.
Appendix C · Source index
| Tutorial step | Open Design source |
|---|---|
| Step 1 thesis | docs/agent-adapters.md:5-11 |
| Step 2 contract | apps/daemon/src/runtimes/types.ts:101-253 |
| Step 2 sample definition | apps/daemon/src/runtimes/defs/claude.ts (98 lines) |
| Step 2 registry + invariant | apps/daemon/src/runtimes/registry.ts:30-76 |
| Step 2 three session-resume styles | apps/daemon/src/runtimes/types.ts:189-209 |
| Step 3 probe pipeline | apps/daemon/src/runtimes/detection.ts:238-318 |
| Step 3 path resolve must agree | apps/daemon/src/runtimes/detection.ts:243-250 (comment) |
| Step 3 auth probe | apps/daemon/src/runtimes/types.ts:228-245 · auth.ts |
| Step 4 stdin and command-line ceilings | apps/daemon/src/runtimes/defs/claude.ts:45-51 (comment) |
| Step 4 stdin lifecycle | docs/agent-adapters.md:191-194 · apps/daemon/AGENTS.md:113 |
| Step 5 stream-format grouping | docs/agent-adapters.md:139-147 |
Step 5 <artifact> extraction |
apps/daemon/src/runtimes/plain-stream.ts (473 lines) |
| Step 5 artifact inventory | apps/daemon/src/runtimes/plain-stream.ts:421-472 |
| Step 6 execution profile | packages/contracts/src/execution-profile.ts |
| Step 6 filesystem delivery contract | apps/daemon/src/prompts/system.ts:549-572 |
| Step 6 API-mode override | packages/contracts/src/prompts/system.ts:498-540 |
| Step 7 design-system package contract | design-systems/README.md |
| Step 7 eight-layer injection order | docs/skills-protocol.md:200-218 |
| Step 7 brand-extract five steps | packages/contracts/src/prompts/discovery.ts:176-187 |
| Step 8 craft four axes | craft/README.md |
| Step 8 two enforcement levels | craft/README.md:63-70 · craft/anti-ai-slop.md |
| Step 9 three hard rules | packages/contracts/src/prompts/discovery.ts:25-354 |
| Step 9 form-writing rules | packages/contracts/src/prompts/discovery.ts:135-152 |
| Step 9 deck framework first | packages/contracts/src/prompts/system.ts:450-485 |
| Step 10 assembler | apps/daemon/src/prompts/system.ts:791-1370 |
| Step 10 priority comment | packages/contracts/src/prompts/system.ts:498-512 |
| Step 11 cache banding | apps/daemon/src/prompts/system.ts:851-867 (comment) |
| Step 11 hit attribution | apps/daemon/src/runtimes/chat-prompt-inputs.ts:394-442 |
| Step 12 anti-AI-slop linter | apps/daemon/src/lint-artifact.ts (1 000 lines) |
| Step 12 tracking token evaluation | apps/daemon/src/lint-artifact.ts:636-720 · 805-892 |
| Step 12 feedback loop | apps/daemon/src/lint-artifact.ts:519-537 |
| Step 13 critique config and schema | packages/contracts/src/critique.ts |
| Step 13 critique docs | docs/critique-theater.md |
| Step 14 memory and three cards | packages/contracts/src/prompts/system.ts:376-404 |
| Step 15 atom catalog | docs/atoms.md · apps/daemon/src/plugins/atoms.ts |
| Step 15 pipeline | apps/daemon/src/plugins/pipeline.ts · pipeline-runner.ts |
| Step 16 authorization boundary | docs/agent-adapters.md:442-465 |
| Step 16 SSRF opt-out | README.md (Internally-hosted model endpoints section) |
| Step 16 desktop HMAC | apps/daemon/src/desktop-auth.ts · docs/architecture.md:228-243 |
| Step 16 skill-staging autopsy | apps/daemon/src/cwd-aliases.ts:1-30 (comment) |
| Step 16 Windows triple guard | docs/agent-adapters.md:345 · runtimes/prompt-budget.ts |
| Step 17 three run shapes | docs/architecture.md:18-53 |
| Step 17 data-root contract | docs/architecture.md:150-162 · root AGENTS.md |
| Step 17 dual-track | apps/daemon/AGENTS.md:99-106 |
| Step 17 no cross-agent fallback | docs/agent-adapters.md:411-424 |
Close: who this route is for
It is for you if: - Your product value is in content and experience, not reasoning ability (design, writing, data analysis, reports) - Your users already have a CLI Agent (developers, designers, technical PMs) - You want to spend energy on encoding domain knowledge (brand contracts, craft rules, quality gates), not rebuilding a loop
It is not for you if: - Your users are non-technical civilians (they will not install a CLI; you would have to rely entirely on BYOK fallback, and that path’s experience is a grade worse) - You need fine-grained permission control (on this route you cannot control the child’s behavior; you can only control the boundary) - Your core competence is reasoning itself (then you should write the loop)
Last sentence: Open Design reached 82k stars in three months not because its adapters are well written—the adapters are only 3 000 lines. It is because it encoded “what good design is” into 151 brand packages, 11 craft-rule files, 330 lines of behavior script, and a 1 000-line aesthetic linter.
The engine is borrowed. The product is yours.
This tutorial is written against nexu-io/open-design commit c893b60 (2026-07-28). Every file:line citation can be verified in source. Companion reading: open-design source analysis.