# 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 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](../analyses/open-design.md)*.
> **Prerequisite**: knowing “Agent loop = the model speaks → software acts for it → results are fed back → until it stops requesting tools” is enough. You **do not** need to know how to design.

---

## Contents

- [Look at the end first: what a “design-engine host” looks like](#end)
- [Step 0 · The scene: one sentence, “make us a landing page”](#s0)
- [Step 1 · Decide not to write an Agent: use the already-installed CLI as the engine](#s1)
- [Step 2 · Adapter is data: one object literal describes one CLI](#s2)
- [Step 3 · Probe: how you know what is on the machine (and a pit you will hit)](#s3)
- [Step 4 · Launch: spawn a child; the prompt goes on stdin, not argv](#s4)
- [Step 5 · Understand its speech: four stream formats, one unified event set](#s5)
- [Step 6 · Catch the artifacts: two execution profiles](#s6)
- [Step 7 · Give it a brand: the `DESIGN.md` contract and token-injection order](#s7)
- [Step 8 · Give it craft: the fourth axis `craft/`](#s8)
- [Step 9 · Give it a script: three hard rules](#s9)
- [Step 10 · Control priority: the prompt is a “who overrides whom” game](#s10)
- [Step 11 · Make it cheap: band by change frequency + cache-hit attribution](#s11)
- [Step 12 · First gate: write “looks fake at a glance” as regex](#s12)
- [Step 13 · Second gate: a five-panelist critique theater](#s13)
- [Step 14 · Make it remember you: a dual memory loop of three cards](#s14)
- [Step 15 · Make it extensible: four planes + atoms + a closed `until`](#s15)
- [Step 16 · Fit the boundary: you gave up the permission gate, so you must complete the perimeter](#s16)
- [Step 17 · Ship it as a product: desktop shell + sidecar + MCP server + export](#s17)
- [🎬 Full replay: what that one sentence actually ran](#replay)
- [Appendix A · Acceptance assertions (how to verify each step)](#a1)
- [Appendix B · Twelve easiest ways to crash](#a2)
- [Appendix C · Source index](#a3)

---

<h2 id="end">Look at the end first: what a “design-engine host” looks like</h2>

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:

```mermaid
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<br/>26 adapter data defs"]
        COMP["prompt assembler<br/>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 /<br/>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.

---

<h2 id="s0">Step 0 · The scene: one sentence, “make us a landing page”</h2>

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 |

---

<h2 id="s1">Step 1 · Decide not to write an Agent: use the already-installed CLI as the engine</h2>

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

---

<h2 id="s2">Step 2 · Adapter is data: one object literal describes one CLI</h2>

### 2.1 First, the wrong approach

You need to support 25 CLIs. The first instinct is an abstract base class:

```ts
// ❌ 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”

```ts
// ✅ 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:

```mermaid
flowchart LR
    DEF["RuntimeAgentDef<br/>(pure data)"] --> E1["detection.ts<br/>probe"]
    DEF --> E2["launch.ts<br/>path resolve + env"]
    DEF --> E3["invocation.ts<br/>argv assemble + spawn"]
    DEF --> E4["run lifecycle<br/>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):

```ts
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

```ts
// 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**.

---

<h2 id="s3">Step 3 · Probe: how you know what is on the machine (and a pit you will hit)</h2>

### 3.1 Probe pipeline

```mermaid
flowchart TB
    S["probe(def, configuredEnv)"] --> R["① resolveAgentLaunch(def, env)<br/>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**.

---

<h2 id="s4">Step 4 · Launch: spawn a child; the prompt goes on stdin, not argv</h2>

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

```ts
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.

---

<h2 id="s5">Step 5 · Understand its speech: four stream formats, one unified event set</h2>

### 5.1 Classify, do not enumerate

25 CLIs, 25 stdout formats? No. After classifying by wire format there are only **7 `streamFormat`s, 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:

```html
<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**.

```ts
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:

```ts
// .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.

---

<h2 id="s6">Step 6 · Catch the artifacts: two execution profiles</h2>

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

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

```mermaid
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<br/>❌ must not also emit an &lt;artifact&gt; source block"]

    P -->|text_artifact| T1["the model loop has no file tools"]
    T1 --> T2["the only delivery form: one complete &lt;artifact&gt; 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.


<h2 id="s7">Step 7 · Give it a brand: the <code>DESIGN.md</code> contract and token-injection order</h2>

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`:

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

```mermaid
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<br/>(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.

---

<h2 id="s8">Step 8 · Give it craft: the fourth axis <code>craft/</code></h2>

### 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.md`s? 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:

```yaml
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.


<h2 id="s9">Step 9 · Give it a script: three hard rules</h2>

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

```mermaid
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 + <question-form id="discovery"> + stop
    Note right of A: ❌ no file reads ❌ no Bash<br/>❌ 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):

```ts
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.

---

<h2 id="s10">Step 10 · Control priority: the prompt is a “who overrides whom” game</h2>

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

---

<h2 id="s11">Step 11 · Make it cheap: band by change frequency + cache-hit attribution</h2>

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

```mermaid
flowchart LR
    Z1["① globally static<br/>designer charter · injection resistance<br/>[shared by all sessions]"] --> Z2["② session-stable<br/>mode override · locale<br/>[unchanged inside one session]"]
    Z2 --> Z3["③ project-stable<br/>design system · skills · metadata · platform<br/>[unchanged inside one project]"]
    Z3 --> Z4["④ turn-variable<br/>deck/media/platform signal-triggered blocks<br/>[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**:

```ts
// 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:

```ts
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.


<h2 id="s12">Step 12 · First gate: write “looks fake at a glance” as regex</h2>

### 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:

```js
// ❌ 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:

```mermaid
flowchart TB
    S1["① extractCssTokens(html)<br/>collect --name: value per scope"] --> S2
    S2["② buildResolvedThemes(scopes)<br/>compose global theme scopes (:root, [data-theme=…])<br/>into multiple theme sets"] --> S3
    S3["③ resolveCssVars(body, tokens)<br/>recursively resolve var(--x), max depth 4"] --> S4
    S4["④ resolveFontSizePx(decls)<br/>font-size in the same rule converted to px (root=16)"] --> J
    J["Judge: is letter-spacing<br/>under each theme, at that font size,<br/>≥ 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.

---

<h2 id="s13">Step 13 · Second gate: a five-panelist critique theater</h2>

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

```mermaid
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 &lt;PANELIST role='…'&gt; tags"]
        G2 --> G3["parsed into panelist_* events"]
        G1 --> G4["run contract identical to ordinary generation<br/>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`.

---

<h2 id="s14">Step 14 · Make it remember you: a dual memory loop of three cards</h2>

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

```mermaid
flowchart TB
    IN["user short request"] --> MEM{"is memory enough<br/>to expand into a brief?"}
    MEM -->|enough & rewrite=ON| C1["① task-brief card<br/>replaces the turn-1 questionnaire"]
    MEM -->|not enough| FORM["go RULE 1 questionnaire"]
    C1 --> BUILD["TodoWrite + build<br/>[not skippable]"]
    FORM --> BUILD
    BUILD --> SLOP["anti-AI-slop / brand self-check<br/>[not skippable]"]
    SLOP -->|verify=ON & verified rules exist| C2["② verify-scorecard card<br/>host programmatically checks it exists"]
    SLOP --> HAND["delivery wrap-up"]
    C2 --> HAND
    HAND --> FB{"does the user's correction<br/>imply a reusable rule?"}
    FB -->|yes| C3["③ rule-proposal card<br/>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.


<h2 id="s15">Step 15 · Make it extensible: four planes + atoms + a closed <code>until</code></h2>

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

```mermaid
flowchart TB
    M["plugin manifest<br/>od.pipeline.stages[*].atoms[]"] -->|① resolve| PS["PipelineStage[]"]
    PS -->|② before run| B["resolve built-in atom instruction bodies<br/>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<br/>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.

---

<h2 id="s16">Step 16 · Fit the boundary: you gave up the permission gate, so you must complete the perimeter</h2>

### 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/`?

```mermaid
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<br/>refuse imports that land inside hosted storage
    D->>D: stamp a server-controlled "trusted picker" mark
    Note over D: every later file access does safe path resolve against that external root
```

**The key sentence**: that “trusted picker” mark is **server-controlled**; an ordinary project create/update request **cannot forge it**. The renderer never gets the HMAC key.

### 16.5 Boundary 4: preview must be a sandboxed iframe

- Sandboxed iframe, **no host same-origin access**
- Each face **only opt-in to the sandbox features it needs** (download, popups)
- When switching URL/srcDoc render modes, **keep both frames mounted** to avoid reload flicker
- Message handlers **validate the sending iframe**
- Signals that must come from the active frame are **checked against the active window again**

The last two defend: when a page has multiple iframes, a background frame impersonating the active frame and sending messages.

### 16.6 Boundary 5: skill staging must be a copy, not a symlink

This is the most worth-copying one, because it was a **vulnerability caught in code review**.

**Wrong version (PR #435 round 1)**: make `.od-skills/` a directory symlink pointing at the repo `skills/` tree.

**Review comment**:

> **write-amplification vulnerability: agents have write access to their cwd**, and a `Write`/`Edit`/`Bash` call against `.od-skills/<id>/SKILL.md` **resolves through the symlink and mutates the shipped resource itself.**

One `Edit` can break the skill source file shared by all projects.

**Correct version**: a **real copy** per project.

```ts
export const SKILLS_CWD_ALIAS = '.od-skills';

export function skillCwdAliasSegment(dir: string): string {
  const folder = path.basename(dir) || 'skill';
  const normalizedDir = path.resolve(dir).replaceAll('\\', '/');
  const digest = createHash('sha256').update(normalizedDir).digest('hex').slice(0, 10);
  return `${folder}-${digest}`;   // saas-landing-3f2a91b0c4
}
```

Four companion details:

| Detail | Why |
|---|---|
| **Stage only the active skill**, not the whole `skills/` | a single skill is 1–3 MB; on APFS/btrfs/ReFS `fs.cp` is CoW; steady-state cost is a few syscalls |
| **`dereference: true`** | the copy is fully self-contained; **nothing inside it can write back to a real file outside the project** |
| **`stat()` rather than `lstat()`** the source root | some environments put `skills/` itself behind a symlink (content-addressed mounts); follow it |
| **Path hash as suffix** | user root and built-in root may have same-named skills (shadowing); when both are selected, directory names collide |
| **Cross-filesystem streaming-copy fallback** | `copy_file_range(2)` is refused across filesystems (`EXDEV`; container image layers copied onto ZFS/overlay is `EPERM`); Node does not auto-degrade |
| **Give the prompt two paths** | cwd-relative staged path (primary) + absolute source path (fallback); if staging fails the agent can still work |

### 16.7 Boundary 6: a triple guard for Windows command-line length

If you must support a CLI that only eats argv (e.g. clap declares `prompt: String` required, no `-` stdin sentinel), you need three guards:

| Guard | When | Check |
|---|---|---|
| `checkPromptArgvBudget` | **before bin resolve** (fast) | raw prompt byte count vs `maxPromptArgBytes` (e.g. 30 000) |
| `checkWindowsCmdShimCommandLineBudget` | after `buildArgs` | when a `.cmd`/`.bat` shim is resolved, recompute `cmd.exe /d /s /c "<inner>"` with **the same per-arg quote-doubling rules as the platform layer** |
| `checkWindowsDirectExeCommandLineBudget` | after `buildArgs` | when a non-shim `.exe` is resolved, recompute with **libuv `quote_cmd_arg` rules** (each `"` becomes `\"`; backslashes adjacent to quotes double) |

The two Windows guards are **mutually exclusive** for a given resolve. Together they catch: **raw bytes did not overflow, but a quote-dense prompt (code blocks, JSON-shaped skill seeds) expands past CreateProcess’s 32 767 character ceiling.**

All three emit the same actionable error: tell the user “reduce skill/design-system context, shorten the conversation, or switch to an adapter that supports stdin.” **And all three have unit tests** (over-long + short-prompt branches, quote-dense regressions on both Windows paths, mutual-exclusion checks), “so the guards can't silently regress”.

> ✅ **This step’s acceptance**: break `.od-skills/xxx/SKILL.md` → 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`.

---

<h2 id="s17">Step 17 · Ship it as a product: desktop shell + sidecar + MCP server + export</h2>

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

```bash
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.


<h2 id="replay">🎬 Full replay: what that one sentence actually ran</h2>

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.

```mermaid
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<br/>② designer charter<br/>③ discovery layer (3k tokens)<br/>④ linear-app USAGE→DESIGN.md→tokens.css→component inventory<br/>⑤ craft: typography+color+anti-ai-slop<br/>⑥ saas-landing SKILL.md<br/>⑦ metadata block<br/>⑧ 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',<br/>'--output-format','stream-json','--verbose',<br/>'--include-partial-messages','--session-id',<uuid>,<br/>'--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: <question-form id="discovery">…</question-form>
    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: <artifact-lint> 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: <PANELIST role="critic">8.5</PANELIST> …
    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 /<br/>social proof / pricing / CTA, all bound to Linear tokens."
    Note right of C: ❌ do not emit an <artifact> 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.**

---

<h2 id="a1">Appendix A · Acceptance assertions (how to verify each step)</h2>

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

---

<h2 id="a2">Appendix B · Twelve easiest ways to crash</h2>

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

---

<h2 id="a3">Appendix C · Source index</h2>

| Tutorial step | Open Design source |
|---|---|
| Step 1 thesis | `docs/agent-adapters.md:5-11` |
| Step 2 contract | `apps/daemon/src/runtimes/types.ts:101-253` |
| Step 2 sample definition | `apps/daemon/src/runtimes/defs/claude.ts` (98 lines) |
| Step 2 registry + invariant | `apps/daemon/src/runtimes/registry.ts:30-76` |
| Step 2 three session-resume styles | `apps/daemon/src/runtimes/types.ts:189-209` |
| Step 3 probe pipeline | `apps/daemon/src/runtimes/detection.ts:238-318` |
| Step 3 path resolve must agree | `apps/daemon/src/runtimes/detection.ts:243-250` (comment) |
| Step 3 auth probe | `apps/daemon/src/runtimes/types.ts:228-245` · `auth.ts` |
| Step 4 stdin and command-line ceilings | `apps/daemon/src/runtimes/defs/claude.ts:45-51` (comment) |
| Step 4 stdin lifecycle | `docs/agent-adapters.md:191-194` · `apps/daemon/AGENTS.md:113` |
| Step 5 stream-format grouping | `docs/agent-adapters.md:139-147` |
| Step 5 `<artifact>` extraction | `apps/daemon/src/runtimes/plain-stream.ts` (473 lines) |
| Step 5 artifact inventory | `apps/daemon/src/runtimes/plain-stream.ts:421-472` |
| Step 6 execution profile | `packages/contracts/src/execution-profile.ts` |
| Step 6 filesystem delivery contract | `apps/daemon/src/prompts/system.ts:549-572` |
| Step 6 API-mode override | `packages/contracts/src/prompts/system.ts:498-540` |
| Step 7 design-system package contract | `design-systems/README.md` |
| Step 7 eight-layer injection order | `docs/skills-protocol.md:200-218` |
| Step 7 brand-extract five steps | `packages/contracts/src/prompts/discovery.ts:176-187` |
| Step 8 craft four axes | `craft/README.md` |
| Step 8 two enforcement levels | `craft/README.md:63-70` · `craft/anti-ai-slop.md` |
| Step 9 three hard rules | `packages/contracts/src/prompts/discovery.ts:25-354` |
| Step 9 form-writing rules | `packages/contracts/src/prompts/discovery.ts:135-152` |
| Step 9 deck framework first | `packages/contracts/src/prompts/system.ts:450-485` |
| Step 10 assembler | `apps/daemon/src/prompts/system.ts:791-1370` |
| Step 10 priority comment | `packages/contracts/src/prompts/system.ts:498-512` |
| Step 11 cache banding | `apps/daemon/src/prompts/system.ts:851-867` (comment) |
| Step 11 hit attribution | `apps/daemon/src/runtimes/chat-prompt-inputs.ts:394-442` |
| Step 12 anti-AI-slop linter | `apps/daemon/src/lint-artifact.ts` (1 000 lines) |
| Step 12 tracking token evaluation | `apps/daemon/src/lint-artifact.ts:636-720` · `805-892` |
| Step 12 feedback loop | `apps/daemon/src/lint-artifact.ts:519-537` |
| Step 13 critique config and schema | `packages/contracts/src/critique.ts` |
| Step 13 critique docs | `docs/critique-theater.md` |
| Step 14 memory and three cards | `packages/contracts/src/prompts/system.ts:376-404` |
| Step 15 atom catalog | `docs/atoms.md` · `apps/daemon/src/plugins/atoms.ts` |
| Step 15 pipeline | `apps/daemon/src/plugins/pipeline.ts` · `pipeline-runner.ts` |
| Step 16 authorization boundary | `docs/agent-adapters.md:442-465` |
| Step 16 SSRF opt-out | `README.md` (Internally-hosted model endpoints section) |
| Step 16 desktop HMAC | `apps/daemon/src/desktop-auth.ts` · `docs/architecture.md:228-243` |
| Step 16 skill-staging autopsy | `apps/daemon/src/cwd-aliases.ts:1-30` (comment) |
| Step 16 Windows triple guard | `docs/agent-adapters.md:345` · `runtimes/prompt-budget.ts` |
| Step 17 three run shapes | `docs/architecture.md:18-53` |
| Step 17 data-root contract | `docs/architecture.md:150-162` · root `AGENTS.md` |
| Step 17 dual-track | `apps/daemon/AGENTS.md:99-106` |
| Step 17 no cross-agent fallback | `docs/agent-adapters.md:411-424` |

---

## Close: who this route is for

**It is for you if**:
- Your product value is in **content and experience**, not reasoning ability (design, writing, data analysis, reports)
- Your users already have a CLI Agent (developers, designers, technical PMs)
- You want to spend energy on **encoding domain knowledge** (brand contracts, craft rules, quality gates), not rebuilding a loop

**It is not for you if**:
- Your users are non-technical civilians (they will not install a CLI; you would have to rely entirely on BYOK fallback, and that path’s experience is a grade worse)
- You need **fine-grained permission control** (on this route you cannot control the child’s behavior; you can only control the boundary)
- Your core competence *is* reasoning itself (then you should write the loop)

**Last sentence**: Open Design reached 82k stars in three months not because its adapters are well written—the adapters are only 3 000 lines. It is because it **encoded “what good design is” into 151 brand packages, 11 craft-rule files, 330 lines of behavior script, and a 1 000-line aesthetic linter**.

**The engine is borrowed. The product is yours.**

---

*This tutorial is written against `nexu-io/open-design` commit `c893b60` (2026-07-28). Every `file:line` citation can be verified in source. Companion reading: [open-design source analysis](../analyses/open-design.md).*
