Architecture
1. Three deployment topologies
┌──────────────── Topology A — fully local (default) ────────────────┐
│ │
│ browser ──► Next.js dev / static export (localhost:OD_WEB_PORT) │
│ │ │
│ │ HTTP + SSE (proxy /api/* → daemon) │
│ ▼ │
│ Daemon on localhost:OD_PORT (Express + SQLite) │
│ │ spawn() + stdin/stdout │
│ ▼ │
│ code-agent CLI (claude / codex / cursor / …) │
└────────────────────────────────────────────────────────────────────┘
┌──────────── Topology B — Vercel + local daemon (planned) ─────────┐
│ browser ──► od.yourdomain.com ─tunnel─► daemon on laptop │
└───────────────────────────────────────────────────────────────────┘
┌────────── Topology C — Vercel + direct-API BYOK proxy ────────────┐
│ browser ──► /api/proxy/{anthropic,openai}/stream │
│ (no daemon path, internal-IP/SSRF blocked at the edge) │
└───────────────────────────────────────────────────────────────────┘
A is the shipped default; B is documented as design intent; C is the BYOK fallback (apps/daemon/src/server.ts:2209-2287 for Anthropic, 2289+ for OpenAI). Internal-IP blocking is enforced in validateExternalApiBaseUrl() at server.ts:2187-2207.
2. Component diagram
┌────────────────────────── apps/web (Next.js 16 SPA) ──────────────────────────┐
│ │
│ ChatPane · ChatComposer · AgentPicker · QuestionForm · FileWorkspace │
│ PreviewModal · DesignSystemsTab · PromptTemplatesTab · SettingsDialog │
│ ToolCard · AssistantMessage · DesignSpecView · pet/ │
│ │
│ state/ (config, projects, maxTokens, litellm-models) │
│ runtime/ (srcdoc, react-component, tool-renderers, todos, markdown, │
│ exports, zip) │
│ comments.ts (DOM-snapshot → chat-attachment for surgical edits) │
└─────────────────┬──────────────────────────────────┬──────────────────────────┘
│ /api/* (proxied) │ /api/proxy/*/stream
▼ │ (Topology C BYOK)
┌────────────────────────────── apps/daemon ────────────────────────────────────┐
│ │
│ Express server (server.ts ~2400 lines, 70+ routes) │
│ ├─ /api/agents · /api/skills · /api/design-systems · /api/prompt-templates │
│ ├─ /api/projects · /api/projects/:id/conversations · …/messages · …/tabs │
│ ├─ /api/projects/:id/files (CRUD + raw + preview) │
│ ├─ /api/templates · /api/codex-pets · /api/import/claude-design │
│ ├─ /api/artifacts/{save,lint} │
│ ├─ /api/media/{models,config} · /api/projects/:id/media/{generate,tasks} │
│ ├─ /api/deploy/config · /api/projects/:id/deploy │
│ ├─ /api/runs (Managed Agents) │
│ ├─ /api/chat (SSE) ────────► spawns CLI, streams events │
│ └─ /api/proxy/{anthropic,openai}/stream (SSE) ────► fetch upstream API │
│ │
│ Subsystems: │
│ ├─ prompts/{system,discovery,directions,deck-framework, │
│ │ media-contract,official-system}.ts → composeSystemPrompt() │
│ ├─ agents.ts (12 AGENT_DEFS, capability probe, listModels, buildArgs) │
│ ├─ stream parsers: claude-stream · copilot-stream · acp · pi-rpc · │
│ │ json-event-stream │
│ ├─ skills.ts + craft.ts + design-systems.ts (filesystem indexers) │
│ ├─ db.ts (SQLite: projects/conversations/messages/tabs/templates/ │
│ │ preview_comments/deployments) │
│ ├─ projects.ts (path-traversal-safe filesystem CRUD) │
│ ├─ lint-artifact.ts (anti-AI-slop linter — 9 P0 + N P1 patterns) │
│ ├─ media.ts + media-models.ts + media-config.ts (provider router) │
│ ├─ deploy.ts (Vercel) │
│ ├─ artifact-manifest.ts · claude-design-import.ts · runs.ts │
│ └─ codex-pets.ts + community-pets-sync.ts (mascot sprites) │
└────────┬─────────────────────────────────────────────────────────────────────┘
│ child_process.spawn(...)
▼
┌─────────────────────────────────────────────────────────┐
│ Detected agent CLI (claude · codex · devin · cursor · │
│ gemini · opencode · qwen · copilot · hermes · kimi · │
│ pi · kiro), cwd pinned to .od/projects/<id>/ │
└─────────────────────────────────────────────────────────┘
Three thin Electron entry points live alongside this:
apps/desktop/— Electron shell; discovers the web URL via sidecar IPC (STATUSmessage), opens a window. Self-terminates if its parent (tools-dev) dies.apps/packaged/— pre-flight runtime for the packaged bundle: starts daemon + web sidecars and registers theod://protocol before delegating toapps/desktop.
3. Workspace boundaries (the packages/)
packages/
├── contracts/ Pure TS DTOs shared between web and daemon.
│ api/{chat,projects,artifacts,comments,files,proxy,registry}
│ sse/chat · prompts/system · common
│ RULE: no Next, Express, fs, browser, sqlite, sidecar deps.
├── sidecar-proto/ Open Design *business* sidecar protocol.
│ 5-field stamp = { app, mode, namespace, ipc, source }
│ IPC messages: STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN
│ Namespace validation (alphanumeric + ._-, ≤128 chars).
├── sidecar/ Generic sidecar runtime: bootstrap, IPC server/client,
│ path resolution, launch env. Consumes a contract descriptor;
│ does not hard-code OD constants.
└── platform/ OS-level process primitives. createProcessStampArgs(),
readProcessStamp(), matchesStampedProcess(),
listProcessSnapshots(). Cross-platform (ps/wmic).
The point of this split: tools/dev and tools/pack are orchestration layers that only call package primitives — never hand-build --od-stamp-* argv or process-scan regexes. This keeps stamp formats in one place and makes "two concurrent namespaces on one machine" a property of the system rather than something each entrypoint reinvents.
4. Data flow — a typical "make me a deck" turn
1. User selects skill + design system in EntryView, types a brief.
(or: types prompt, then NewProjectPanel infers metadata)
2. Web POSTs /api/projects → daemon writes row + creates .od/projects/<id>/ dir.
3. Web POSTs /api/chat with { agentId, projectId, conversationId, message,
skillId, designSystemId, model, attachments, commentAttachments, ... }.
The route at server.ts:2174 creates a run via design.runs.create() and
immediately returns SSE. Body of the run is `startChatRun()`:
server.ts:1856–2080
├─ Load skill → load design-system → load craft refs (via opt-in)
├─ composeSystemPrompt({ skillBody, designSystemBody, craftBody,
│ skillMode, metadata, template })
│ stack order = discovery → official designer → DESIGN.md →
│ craft → skill body → metadata → deck framework
│ (last) | media contract (last)
├─ Resolve cwd = .od/projects/<id>/, sanitise attachment paths,
│ build cwdHint + filesListBlock + attachmentHint + commentHint
├─ Build composed user prompt:
│ "# Instructions (read first)\n…\n# User request\n<message>"
│ + image @paths (claude-style)
├─ Call def.buildArgs(composedPrompt, safeImages, extraAllowedDirs,
│ { model, reasoning }, { cwd })
│ → returns CLI argv. Most agents take prompt via stdin (avoids
│ Windows ENAMETOOLONG ~32KB cmdline limit).
├─ child = spawn(invocation.command, invocation.args, {
│ cwd, stdio: ['pipe','pipe','pipe'], env: { OD_BIN, OD_DAEMON_URL,
│ OD_PROJECT_ID, …upstream } })
└─ Wire stream parser keyed off def.streamFormat:
claude-stream-json | copilot-stream-json | acp-json-rpc |
pi-rpc | json-event-stream | plain
Each parser emits typed events that fan out to:
(a) SSE to the browser
(b) DB upsertMessage events_json
(c) artifact-save → lint feedback to agent
4. Browser displays:
- Live "Todos" card from TodoWrite tool calls (todos.ts)
- ToolCard rows for each tool_use
- AssistantMessage text deltas as they arrive
- PreviewModal/iframe loads the produced HTML when artifact appears
5. On <artifact> emission OR at done:
POST /api/artifacts/save runs lint-artifact:
- Detects 9 P0 anti-slop patterns; returns findings to agent
- Agent self-corrects on next turn via systemMessage feedback
6. User clicks an element in the preview (comment mode):
- apps/web/src/comments.ts captures DOM snapshot
→ PreviewCommentTarget { path, selector, position, html_hint }
- Saved as preview_comments row, sent as commentAttachment with the
next chat turn → agent receives surgical-edit instruction.
5. Filesystem layout at runtime
<project root>
├── .od/ # daemon-owned data (or OD_DATA_DIR/...)
│ ├── app.sqlite # SQLite metadata (WAL mode + foreign keys)
│ └── projects/<id>/ # one dir per project, cwd of every spawned agent
│ ├── index.html # primary artifact
│ └── … # whatever the agent wrote
├── .tmp/<source>/<namespace>/... # transient sidecar runtime files
└── /tmp/open-design/ipc/<namespace>/<app>.sock # POSIX IPC sockets
skills/<id>/
├── SKILL.md # frontmatter + workflow body
├── assets/ # template seeds, base.html, icons
└── references/ # layouts.md, themes.md, components.md, checklist.md
design-systems/<id>/DESIGN.md # 9-section schema (awesome-claude-design)
craft/<slug>.md # universal brand-agnostic rules
prompt-templates/{image,video}/ # gpt-image-2 / Seedance / HyperFrames examples
6. Sidecar process stamps
Every process the lifecycle layer spawns is stamped. From the AGENTS.md root rule: stamps must have exactly five fields — app, mode, namespace, ipc, source.
$ ps ax | grep daemon
node …/dist/server.js \
--od-stamp-app=daemon \
--od-stamp-mode=dev \
--od-stamp-namespace=default \
--od-stamp-ipc=/tmp/open-design/ipc/default/daemon.sock \
--od-stamp-source=tools-dev
packages/platform's createProcessStampArgs() writes them; readProcessStamp() reads them; matchesStampedProcess() lets tools-dev status find live processes by stamp without a PID file. Why five fields: app identity, dev-vs-runtime mode, isolation namespace, IPC socket address, what spawned it (tools-dev / tools-pack / packaged). namespace is the killer feature — multiple concurrent local sessions don't trample each other's IPC sockets or .tmp/ paths.
7. Security boundaries
| Surface | Threat | Mitigation in code |
|---|---|---|
/api/proxy/*/stream |
SSRF to internal services | validateExternalApiBaseUrl() rejects localhost / 127/8 / 169.254/16 / 10/8 / 172.16-31/12 / 192.168/16, and non-http(s) schemes (server.ts:2187-2207). |
| Agent reads outside project | Path-escape via attachments |
safeAttachments filter requires path.resolve(cwd, p).startsWith(cwd + sep) (server.ts:1919-1933). Same pattern in file CRUD, image upload, media --image resolution (media.ts:102-156). |
| Spawned agent over-reach | Reading user $HOME | cwd pinned to .od/projects/<id>/; only --add-dir opens skills + design-systems explicitly (server.ts:1977-1979). |
| User secrets | Leak to daemon's logs | redactAuthTokens() strips Bearer … from upstream errors before logging (server.ts:2185). |
| Agent autonomy | Hangs on permission prompts | All agents launched with --permission-mode bypassPermissions/--full-auto/--dangerously-skip-permissions/--allow-all-tools (per agent in agents.ts). Tradeoff: the agent runs un-prompted, so the project cwd is the only sandbox. |
| Prompt-template injection | User pastes ``` to break out of fence | safe = tpl.prompt.replace(/```/g, '')(zero-width-space inserted,system.ts:333`). |
| Image upload in media | Path traversal + DoS | resolveProjectImage() checks resolved path is under project root; caps at 16 MB; MIME allowlist png/jpg/webp/gif (media.ts:102-156). |
| Hallucinated CLI args | $$$ over-billing | clampNumber() snaps duration/length to nearest allowed bucket; clampCodexReasoning() maps user reasoning effort to per-model valid values (agents.ts:78-95). |
| ZIP import | Zip-slip / billion-laughs | claude-design-import.ts rejects encrypted entries, validates compression methods, sanitises paths (no \0, absolute, ..), caps 500 files / 100 MB total / 25 MB per file. |
8. Performance & rough budgets
The aspirations stated in docs/architecture.md:325-330 (and the code is consistent with them):
- Daemon startup: < 500 ms
- Agent detection: < 200 ms (parallel
which) - Agent run latency: dominated by model time; daemon overhead < 50 ms
- Preview reload: 100 ms debounced on artifact writes
- Skill index: cold scan < 100 ms for ~50 skills (
chokidarwatch in dev)
No shared-state caches that I could find — skill scanning is on-demand per request, not watched, in MVP.