# Architecture Clay is not a CLI wrapper. It is a self-hosted daemon that drives **Claude Code** (via the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk)) and **Codex** (via the `codex app-server` JSON-RPC protocol) through a vendor-agnostic adapter layer (**YOKE**), and serves a multi-user web workspace over HTTP/WS. Everything runs on your machine. Sessions, Mates, knowledge, and settings live as plain JSONL/Markdown on disk. No cloud relay, no proprietary database. ## System Overview ```mermaid graph LR subgraph Clients B1["Browser
User A"] B2["Browser
User B"] Phone["Phone PWA
+ Push"] end subgraph Daemon["Clay Daemon (your machine)"] Auth["Auth + RBAC
users / users-auth /
users-permissions"] WS["HTTP / WS Server
server.js + server-*.js"] Project["Project Context
project.js + project-*.js"] YOKE["YOKE Adapter Layer
lib/yoke"] MCP["Built-in MCP servers
ask-user, browser,
debate, email"] Push["Push Service
VAPID / web-push"] end subgraph Vendors["Agent runtimes"] Claude["Claude Agent SDK
in-process or worker"] Codex["codex app-server
JSON-RPC stdio"] end B1 <-->|WS| WS B2 <-->|WS| WS Phone <-->|WS + push| WS WS --> Auth WS --> Project Project --> YOKE Project --> MCP Project --> Push YOKE --> Claude YOKE --> Codex Push -.->|web-push| Phone ``` ## CLI ↔ Daemon Clay decouples the CLI from the long-running server. The CLI starts (or attaches to) a detached daemon over a Unix domain socket. ```mermaid graph TB CLI1["npx clay-server
(Terminal 1)"] CLI2["npx clay-server --add ."] IPC["Unix socket
~/.clay/daemon.sock"] Daemon["Daemon Process
lib/daemon.js"] HTTP["HTTP / WS
:2633"] P1["Project A"] P2["Project B (worktree)"] Sessions1["~/.clay/sessions/<cwd>/*.jsonl"] Sessions2["~/.clay/sessions/<cwd>/*.jsonl"] CLI1 <-->|IPC JSON-lines| IPC CLI2 <-->|IPC JSON-lines| IPC IPC <--> Daemon Daemon --- HTTP HTTP -->|/p/project-a/| P1 HTTP -->|/p/project-a--branch/| P2 P1 --- Sessions1 P2 --- Sessions2 ``` The daemon is spawned with `detached: true` and survives CLI exit. Multiple CLI instances share one daemon. IPC commands include `add_project`, `remove_project`, `set_pin`, `set_keep_awake`, `shutdown`, `get_status`. ## YOKE Adapter Layer YOKE (Yoke Overrides Known Engines) is the vendor abstraction. Each adapter implements the same contract (`init`, `createQuery`, etc.), so `project.js` never has to know which agent runtime is driving a session. ```mermaid graph LR Project["Project Context"] Yoke["yoke/index.js
+ instructions merger"] ClaudeAdapter["yoke/adapters/claude.js"] CodexAdapter["yoke/adapters/codex.js"] ClaudeWorker["claude-worker.js
(OS-isolated user, optional)"] ClaudeSDK["@anthropic-ai/claude-agent-sdk"] CodexBin["@openai/codex
app-server binary"] MCPBridge["mcp-bridge-server.js
(stdio MCP for Codex)"] Project --> Yoke Yoke --> ClaudeAdapter Yoke --> CodexAdapter ClaudeAdapter --> ClaudeWorker ClaudeAdapter --> ClaudeSDK CodexAdapter --> CodexBin CodexAdapter --> MCPBridge MCPBridge -.->|HTTP /api/mcp-bridge| Project ``` **Cross-vendor instruction merging.** On every `createQuery`, YOKE scans the project for instruction files (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, …), drops the ones the current vendor reads natively, and merges the rest into `systemPrompt`. The result: a Codex session sees `CLAUDE.md` content, a Claude session sees `AGENTS.md` content. Switching vendors does not break project context. **Codex specifics.** Codex talks to Clay via JSON-RPC 2.0 over its app-server's stdin/stdout (`yoke/codex-app-server.js`). Approval events, skill injection, and MCP tool calls travel as RPC messages. Clay's tools that Codex needs (e.g. ask-user, debate) are exposed through a stdio MCP bridge (`yoke/mcp-bridge-server.js`) that proxies to the daemon over HTTP. For deeper Codex integration patterns and gotchas, see [CODEX-INTEGRATION.md](./CODEX-INTEGRATION.md). ## Multi-User and OS-Level Isolation Clay supports two modes: - **Single-user.** PIN auth, all sessions run as the daemon's UID. - **Multi-user.** Each user has an account, optional invites, OTP, RBAC permissions, and (on Linux) an OS-level Linux account. When OS-level isolation is enabled on Linux: ```mermaid graph TB Browser["Browser
(authenticated as alice)"] Daemon["Clay daemon
(privileged)"] Worker["claude-worker.js
(spawned as alice)"] Codex["codex app-server
(spawned as alice)"] Files["Project files"] Browser -->|WS msg| Daemon Daemon -->|spawn under
alice's UID/GID| Worker Daemon -->|spawn under
alice's UID/GID| Codex Daemon -->|setfacl grants
alice rwx on project| Files Worker -->|reads/writes| Files Codex -->|reads/writes| Files ``` `os-users.js` is the shared utility. It validates Linux usernames, resolves UID/GID via `getent passwd`, runs filesystem operations as the target user via a helper subprocess, and grants ACL access via `setfacl`. The kernel enforces isolation: one user's agent cannot read another user's project files even if Clay's code has a bug. User provisioning lives in `daemon.js` (`provisionLinuxUser`, `grantProjectAccess`). All worker spawns route through `os-users.resolveOsUserInfo`. ## Permission Flow Every tool call goes through a vendor-neutral approval gate. ```mermaid sequenceDiagram participant B as Browser participant S as Daemon participant Y as YOKE Adapter participant V as Vendor (Claude / Codex) B->>S: user message (WS) S->>Y: enqueue prompt Y->>V: forward V-->>Y: stream deltas + tool requests Y-->>S: deltas / tool_start S-->>B: real-time broadcast Note over Y,S: Approval needed V-->>Y: approval request Y->>S: pendingPermissions[id] = Promise S-->>B: permission_request (all WS clients) S-->>B: web-push to phone (if subscribed) B->>S: permission_response (allow / deny) S->>Y: resolve(decision) Y->>V: continue / abort tool ``` `project-sessions.js` owns `permission_response`. `project-notifications.js` formats the alarm and may also fire push. ## Session Storage Sessions live at `~/.clay/sessions/{encoded-cwd}/{cliSessionId}.jsonl`, one JSONL line per event: ``` {"type":"meta","localId":1,"cliSessionId":"...","title":"...","createdAt":...,"vendor":"claude"} {"type":"user_message","text":"..."} {"type":"delta","text":"..."} {"type":"tool_start","id":"...","name":"..."} {"type":"mention_user","fromUserId":"...","targetUserId":"...","text":"..."} ... ``` Append-only. At most the last line is lost on a crash. The daemon replays all session files on restart. Knowledge files (Mates' memory) live as Markdown under `~/.clay/mates//knowledge/`. Settings are JSON in `~/.clay/`. ## Multi-Project Routing ``` / → Dashboard (auto-redirect if only one project) /p/{slug}/ → Project UI /p/{slug}/ws → Project WebSocket /p/{slug}/api/... → Project HTTP (push subscribe, file access, image, …) /p/{parent}--{branch}/ → Worktree (auto-detected child of parent project) ``` Slugs are auto-generated from the directory name. Duplicates get `-2`, `-3`, etc. Worktrees are scanned by the parent slug (`daemon-projects.js`) and registered with a `parent--branch` slug pattern. ## Built-in MCP Servers The daemon ships four MCP servers that any session (Claude or Codex) can call: | Server | File | Provides | |---|---|---| | `ask-user` | `ask-user-mcp-server.js` | Pause execution and ask the human a question | | `browser` | `browser-mcp-server.js` | Open / navigate / click / screenshot / extract via the user's connected browser | | `debate` | `debate-mcp-server.js` | Spawn a structured multi-Mate debate from inside an agent run | | `email` | `email-mcp-server.js` | Read / send / search across IMAP/SMTP accounts the user has configured | User-defined MCP servers from `~/.clay/mcp.json` are loaded on top. For Codex sessions, all of the above (plus user MCPs) are exposed through a single stdio bridge (`yoke/mcp-bridge-server.js`), which proxies tool list / call back to the daemon over HTTP at `/api/mcp-bridge`. For deeper MCP details, see [MCP-IMPLEMENTATION.md](./MCP-IMPLEMENTATION.md). ## Push Notifications `push.js` generates a VAPID key pair on first run (`~/.clay/vapid.json`, mode 600) and uses `web-push` for delivery. Subscriptions are stored per-user. Push fires on: - Permission requests - Task completion / errors - @mentions of the user (only when the user has no live WS connection) - DM messages from a Mate or another user (when offline) The service worker on the client (`lib/public/sw.js`) handles incoming pushes and routes the user back to the right session on tap. ## Key Design Decisions | Decision | Rationale | |---|---| | Unix socket IPC | CLI ↔ daemon without burning an extra TCP port | | Detached daemon | Server outlives the CLI; sessions keep running after `exit` | | JSONL session storage | Append-only, crash-friendly, `cat`/`grep`-friendly | | Vendor adapters under YOKE | Adding a new agent runtime is an adapter, not a refactor | | Cross-vendor instruction merge | Switching Claude ↔ Codex doesn't lose project context | | Codex via JSON-RPC stdio | Approval events and skills need real-time RPC, not exec mode | | OS-level isolation (Linux) | Trust the kernel for user separation, not application code | | Slug-based routing | Many projects, one port, predictable URLs | | `0.0.0.0` + PIN | LAN access by default; recommend Tailscale for remote | ## See Also - [MODULE_MAP.md](./MODULE_MAP.md) — where every module lives and what it owns - [CODEX-INTEGRATION.md](./CODEX-INTEGRATION.md) — Codex-specific patterns and gotchas - [MCP-IMPLEMENTATION.md](./MCP-IMPLEMENTATION.md) — MCP server architecture (local + bridged) - [NO-GOD-OBJECTS.md](./NO-GOD-OBJECTS.md) — module size principles - [STATE_CONVENTIONS.md](./STATE_CONVENTIONS.md) — state management rules