English lecture · source-traced

Building an “AI design agent” from scratch (Open Design lineage) · full development tutorial

**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

EN
lecture
source
file:line
no
embellishment
中文
sibling

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 Design file:line, baseline commit c893b60 (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.


Part 1

Contents


Part 2

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:

  1. It does not reason itself—reasoning is the claude / codex / cursor-agent already on your machine;
  2. Its artifacts are for humans to look at, not for a compiler, so besides “is it correct” there is “does it look good”;
  3. A large fraction of “looking good” can be programmatically enforced (Tailwind indigo, two-stop gradients, emoji as icons, ALL CAPS without tracking);
  4. Brand is a versionable contract, not a scatter of CSS variables;
  5. 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.


Part 3

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

Part 4

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.


Part 5

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-84 records 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.ts is 131 bytes in full; it is a flag map
  • ❌ a surgicalEdit / streaming / resume feature-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.


Part 6

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.2 The pit you will hit: probe and execution must share one path resolver

This is the only truly important sentence of the step. The comment at detection.ts:243-250 is a complete field record:

Detection must probe the exact path the runtime will spawn, not just the PATH-visible shim. This is load-bearing for Codex under nvm/fnm/mise: the discovered codex entry is often a #!/usr/bin/env node wrapper that is not invocable from a GUI-launched app's stripped PATH, while the launch resolver can still upgrade it to the packaged native Codex binary. If detection probes the shim but chat/run spawns the native binary, the UI incorrectly reports "not installed" until the user pins CODEX_BIN by hand even though the real launch path is healthy.

In other words: - A GUI-launched App’s PATH is stripped by the system (macOS launchd does not read your .zshrc) - CLIs installed via nvm/fnm/mise are often #!/usr/bin/env node shims, which will not run on that stripped PATH - But your launch resolver may still be able to upgrade to a packaged native binary - So: probe uses path A and says “not installed”; the real run uses path B and actually works → the user sees a red X but the function is fine

Rule: the first thing the probe entry does must be to call the exact same path-resolution function spawn uses.

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 authProbe actively probe auth
  • Those that did not → authStatus is 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, then done. 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 claude off 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.


Part 7

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/usage rather than at a mid-tool tool_use pause.

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.


Part 8

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.


Part 9

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, and WebFetch are 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/Edit tools.

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 plain adapter; the artifact files should land in the same place, with the same inventory, as a filesystem-profile run.

Part 10

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:

  1. Locate the source: if there are attachments, list them; if a URL was given, WebFetch <brand>.com/brand, /press, /about
  2. Download style artifacts: CSS, brand-guide PDFs, screenshots
  3. 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
  4. 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)
  5. 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 :root tokens should swap as a whole; break one value in tokens.css; the guard should report derived-file inconsistency.


Part 11

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
  • #6366f1 is 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-ux and 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:craft should error and point at the manifest path; the same error at runtime should only skip that paragraph.

Part 12

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 default that trails a long options array 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, option value, 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 / target all answer “target platform”; slideCount / slides / pageCount all answer “page count”; artifactKind / mode / taskKind already 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.


Part 13

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.


Part 14

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-hash is 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' and changedSections precisely names the design-system section.

Part 15

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 innermost selector { 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 ipsum and feed the linter → should report 3 P0s; give it an uppercase heading using letter-spacing: var(--tracking) with --tracking: 0.08em → must not false-positive.


Part 16

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 interrupted chip 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:degraded for 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 be Interrupted at round N, best composite X.X.


Part 17

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.

Part 18

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 doctor reports 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 to until: "myCustomThing == true" → od plugin doctor should report an unknown signal.


Part 19

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.1 by default
  • LAN exposure requires both OD_BIND_HOST and OD_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.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 → source skills/ should be untouched; fill http://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 emit AGENT_PROMPT_TOO_LARGE before spawn, not an unreadable ENAMETOOLONG.


Part 20

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.md section 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 od CLI 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 --json plus --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
PDF 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 CLI
  • mocks/recordings/ — recorded real streams
  • mocks/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 | jq can 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.

Part 21

🎬 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.


Part 22

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

Part 23

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.


Part 24

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

Part 25

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.

↑