AGENTS.md · git:20261019.42d8102 · 2026-10-19 · sha256 0a37fdf518369d34
AGENTS.md git:20261019.42d8102A
Immutable. This exact content never changes and is served at /api/v1/blob/0a37fdf518369d34.
# Agent Notes for Coddy
Short map for automation-friendly contributors.
## Repository navigation
| Area | Responsibility |
|------|------------------|
| `cmd/coddy` | CLI entry (bare `coddy` console, `cli`, `acp`, **`serve`**, `sessions`, `skills`, `plugin`, `mcp`, `providers login`, `rules list`, `update`), plus **`-t` / `--test-config`** on the console, `acp` and `serve`: the config check in **`internal/config/check.go`** (schema stage in **`schema.go`** over the embedded **`config.schema.json`**, then the loader's rules), reported as `file:line:col` with a fix line, exit 1 on errors, nothing written; and **`--dry-run`** on the same three, which runs that check and then **`internal/dryrun`**. |
| `internal/dryrun` | The **`--dry-run`** probes over a loaded config: **`Prepare`** (the static check, then **`config.LoadReadOnly`** and a **`config.Locator`** so every finding carries its line), **`Run`** (paths in **`paths.go`**, LLM providers through **`llm.ListModels`** with the configured models checked against the list in **`providers.go`**, listen addresses (the exporter's too, with a warning when it would be bound off loopback with no token), the Telegram token, remote MCP servers, configured remotes and the `--remote` target, swarm joins and upstreams in **`network.go`**), and the checklist **`Report`** (`ok` / `warning` / `error` / `skipped` per config path, exit 1 on errors; alone the flag prints problems and the status line, with **`--test-config`** the config report and every probe). The servers of `<home>/mcp.json` only: project-local `.coddy/mcp.json` stays behind the trust gate. `coddy serve` hands it the subsystems **`serve.Resolve`** enabled and their addresses (**`cmd/coddy/dryrun.go`**). |
| `internal/serve` | What **`coddy serve`** is: the **`Runtime`** every subsystem shares (paths, logger, one session store and one **`session.Manager`**, the turn-mirror slot, and the surfaces that offered to show a detached subagent's permission prompt - the HTTP server, a Telegram bot - which are all asked at once, the first answer winning, and the process **background waker** (**`wake.go`**: a finished **`notify_on_finish`** task's turn goes to the surface that offered it first by rank - the Telegram chat bound to the session, then the HTTP server - or runs through the manager when neither is up), the **`Supervisor`** that runs them (**`subsystem.go`**: a **`Subsystem`** descriptor per surface with its **`enable`** key, its build tag and its **`Available`** flag, so an enabled surface the binary cannot run is refused by name, plus **`Fingerprint`** for what can be rebuilt in place and **`RestartKey`** for what only a fresh process can adopt, **`Auxiliary`** for a subsystem that serves beside the surfaces and is never the only one (**`KindMetrics`**, the exporter: it does not count as enabled and may always stop); **`supervisor.go`**: one goroutine per surface with panic recovery, a diff applied on every config reload - start, stop or rebuild only what moved - **`ErrRestartRequested`** when a **`RestartKey`** moves under a **`Restartable`** process, shutdown in reverse declaration order so intake stops before the API, and **`Supervisor.Status`**, the live table **`Runtime.Status`** reads), the **status snapshot** (**`status.go`**: the **`Snapshot`** a running process reads off itself - its build, the subsystem rows with their state, **`running`**, **`starting`**, **`stopped`** or **`retained`**, **`RestartRequired`** derived when read and never recorded, the turns running and the oldest one's start, the unfinished background tasks of the model by kind and status, the disk of the session store, the prompts that wait for a person, the provider quota windows and the swarm state - each part read on its own from **`Supervisor.Status`**, **`bgtask.Pool.CountUnfinished`**, **`session.Manager.OldestTurnStartedAt`**, **`platform.DiskFree`**, **`agent.PendingPrompts`**, **`session.Manager.CachedProviderUsage`** (the usage cache only, never a request) and **`swarm.Joins`** / **`swarm.RelayNodes`**, a part with no source left out; the exporter reads one per scrape), the **status document** (**`status_page.go`**: **`Runtime.StatusDocument`** / **`RenderStatus`**, what **`GET /coddy/status`** answers and **Settings → System** draws - the build, how the process started, the subsystems with their addresses, the disk, the activity and the **`attention`** list decided in Go, with the exporter's last hour through **`SetMetricsSource`** in a binary built with **`metrics`**), and the **background dispatcher** (**`dispatcher.go`**: the restart policy and the **`Record`** at **`<home>/serve.json`**; **`daemon.go`**: **`StartDetached`**, **`RunDispatcher`**, the worker process and the **`CODDY_SERVE_ROLE`** split, exit status **`ExitRestart`** = 75), and the **systemd user service** (**`systemd.go`**: **`UserService`** behind **`coddy serve install`** / **`uninstall`**, the unit renderer shared with the package, the **`PATH`** drop-in, the role **`service`** the unit sets so a listen-address change exits 75 under systemd too). Untagged: it names no tagged package, and the surfaces reach it through **`Available`** consts and stubs. Guide: **`docs/operate/serve.md`**. |
| `internal/agent` | ReAct orchestration, MCP/tool wiring, and the opt-in wait for a hit usage limit (**`limit_wait.go`**: a **`llm.QuotaResetError`** from the resilient wrapper makes a top-level turn wait for the reset under **`agent.wait_for_limit_reset`**, sending **`provider_usage`** updates with **`resuming`** every 20 s, then re-issue the same call). |
| `internal/mcp` | MCP transports, merged server list (**`<home>/mcp.json`** and the project's **`.coddy/mcp.json`** only: **`config.yaml`** declares no server, and **`internal/config/legacy_keys.go`** moves an old **`mcp_servers`** key into the home file on load), and the **workspace trust gate** for project-local **`.coddy/mcp.json`** (**`trust.go`**, **`gate.go`**; policy **`mcp.project_trust`**, approvals in **`<home>/mcp-trust.json`** keyed by the project (**`mcp.TrustWorkspace`**: a linked git worktree shares its main checkout's), **`ReadsEnvironment`** naming on every approval surface the variables a declaration's **`${NAME}`** values read). A server's `command` and `args` run exactly as written, their **`${CWD}`** and **`${NAME}`** resolved by **`config.ExpandMCPValue`** the same way in every file: nothing here knows `npx` or any other package runner. The session manager watches **`<home>/mcp.json`** (**`StartGlobalMCPServers`** starts the watcher, **`ReloadMCPDeclarations`** reconciles only the servers whose declaration or switch moved). **`mcptest`** serves test MCP servers of every kind a user configures - the test binary re-executed on stdio, a local npm package started by the real `npx`, streamable HTTP and SSE - each answering one tool with its token. The **server pool** (**`pool.go`**: **`Pool`**, **`PoolKey`**, **`WorkspaceBound`**) shares the configured servers between sessions: a session holds a lease (**`Client`** is a handle on a shared **`conn`**), a global server is one process for the whole process, a project server that does not name **`${CWD}`** one per project (a linked git worktree shares the one of its main checkout) and any server naming **`${CWD}`** one per workspace, a connection nothing holds stops after **`DefaultStopDelay`** unless the pool keeps it (**`Keep`**: the global servers **`session.Manager.StartGlobalMCPServers`** starts with `coddy serve`, the console and `coddy acp`, ended by **`CloseMCP`**), and a stdio server runs in its own process group, stopped by stdin EOF and then the group (**`client.go`**). The dial itself lives in **`internal/session/mcp_dial.go`** (every configured server through **`TrustGate.Acquire`** under its own 20 s bound, one more try at the next turn for one that did not answer), the manager's side of the pool in **`internal/session/mcp_pool.go`** (a server whose connection ended is parked and started again at the next turn) and the console's background connect in **`internal/session/mcp_background.go`**. Guide: **`docs/features/mcp.md`** (Shared servers). |
| `internal/remote` | Go client for a remote `coddy serve` server: SSE frames back into ACP updates, `/coddy` REST, permission/question answers, and the permission prompts of background subagents the server announces on its events stream (**`detached_prompts.go`**). Powers `--remote` on the console and `coddy acp`. Guide: **`docs/surfaces/console.md`** (Remote mode), **`docs/plans/remote-control.md`**. |
| `internal/bgtask` | Background task pool for detached shell commands (**`run_command`** **`background: true`** plus the **`background_*`** tools, the Tasks panel, and **`/coddy/sessions/{id}/background-tasks`**), for subagent runs started by **`spawn_agent`** (**`Pool.Launch`**, kind **`agent`**, **`Agent {name, session_id}`** on the row), and for in-process servers the **`preview_server`** tool starts (**`Pool.Launch`**, kind **`server`**, **`Spec.URL`** / **`Snapshot.URL`**, **`Spec.NoTimeout`** - no hard limit unless the call names one). **`Pool.Adopt`** takes over a foreground command that outlived its timeout instead of killing it. **`Pool.CanWake`** says whether this process subscribed a waker (**`WakeWatcherKey`**), so **`run_command`** and **`spawn_agent`** promise a **`notify_on_finish`** wake only where one will happen, and **`Pool.MarkWokeAgent`** records on the row (**`woke_agent`**, persisted) that a task's end did start a turn, which is what keeps the bell on a finished card in the Tasks panel and **`woke the agent`** in **`/tasks`**. Guide: **`docs/features/background-tasks.md`**. |
| `internal/canvas`, `internal/tools/canvas` | The canvas of the web UI (**`tools.canvas`**): every board is a `.canvas` file of the workspace (JSON Canvas 1.0, Obsidian's format) whose file nodes naming HTML pages run as live frames, shown as a tab of the Files window. **`board.go`**: **`Ref`** (the chat's bundle, its workspace, the file), **`Load`**, **`Show`** (on the named file, the chat's current one or a new `canvas/<page folder>.canvas`), **`Open`**, **`UpdateNodes`**, **`Rotate`**, **`Remove`**, and the chat's bookkeeping in its bundle (**`canvas.json`**, **`State`**: the current file, the generations of frame addresses and the roots wider than a page's folder, keyed by **`NodeKey`** file and node, applying only in the workspace they were written in), a subagent's pages on the canvas of the top session of its spawn tree and refused from a worktree of its own; **`file.go`**: reading a file into frames, items and edges, writing back only what changed under a per-file lock that makes one file's read-modify-write one turn across chats, **`FrameOf`** for the one frame a file request needs, **`FileStamp`**; **`jsoncanvas.go`** and **`obsidian_json.go`**: an ordered codec that keeps unknown keys and writes byte for byte as Obsidian does. The stamp of a frame's files (**`stamp.go`**), the frame capability (**`capability.go`**: an HMAC in the path naming the session, the file, the frame, its root, workspace and generation, the key derived for the canvas from the secret behind media tokens), serving a frame (**`serve.go`**: **`os.Root`** under the workspace, no links, no dot-names, the sandbox CSP, the bridge script **`bridge.js`** injected), the browser's reports in memory (**`reports.go`**, by session and **`NodeKey`**). **`internal/tools/canvas`** holds **`canvas_show`** and **`canvas_inspect`**; the routes are **`external/httpserver/canvas_http.go`** (every route names its file with **`?file=`**), the relay's exception **`external/swarm`** (**`isCanvasFramePath`**), the board **`external/ui/src/ui/canvas/`**. A request whose **`Origin`** is **`null`** is refused with 403 on every API route but a frame's address (**`external/httpserver/auth.go`**, the relay's gate). Guide: **`docs/features/canvas.md`**, design: **`docs/plans/canvas.md`**. |
| `internal/subagents` | Subagent definitions (markdown + YAML frontmatter from four default folders - **`~/.agents/agents`**, **`.agents/agents`**, **`${CODDY_HOME}/agents`**, **`.coddy/agents`**, always read, the later winning a name - and then the extra **`subagents.dirs`**, through **`config.Subagents.SearchDirs`**; built-ins **`general`**, **`explore`** and the hidden orchestrator **`crossreview`**), canonical **scopes**, the **trust receipts** for project-scope files (**`trust.go`**; policy **`subagents.project_trust`**, receipts in **`<home>/subagents-trust.json`**), the process-wide **`Limiter`**, and the catalog (**`coddy agents list`**, **`GET /coddy/subagents`** with the bounds each definition declares, the prompt block; the web UI lists it under **Settings → Subagents**, where a project definition under **`ask`** carries the MCP shield that approves it, bound to the digest it showed). A definition's **`spawns`** allowlist (builtin/user scope only) admits exactly the named children exactly one level past **`subagents.max_depth`** - the mechanism a coordinator uses to fan out. The runtime that spawns a child is **`internal/agent/subagent.go`**; it also continues a finished run in its own child session when the parent passes **`spawn_agent`** **`resume`**, instead of a second child starting over (issue #389). Child sessions are owned by **`internal/session`**, which stores each one inside the bundle of the session that spawned it (**`<parent>/subagents/<child>/`**) under an ordinary **`sess_`** id and reopens it from that bundle for a resumed run (**`SubagentSpec.Resume`**). Guide: **`docs/features/subagents.md`**. |
| `internal/hooks` | Operator lifecycle hooks: definition files in Claude Code's shape (**`definition.go`**, **`matcher.go`** with tool-name aliases), the loader with canonical **scopes** (**`loader.go`**; policy **`hooks.project_trust`**), the **trust receipts** for project-scope files (**`trust.go`**; **`<home>/hooks-trust.json`**, keyed by the project (**`mcp.TrustWorkspace`**), file and digest; surfaces **`coddy hooks list|trust|untrust`**, **`GET /coddy/hooks`**, **`POST /coddy/hooks/trust|untrust`**), the **catalog** (**`catalog.go`**), and the **runner** (**`runner.go`**: stdin JSON payload, exit-code and JSON answer, timeouts and process groups through **`internal/platform`**). **`internal/agent/hooks.go`** wires `PreToolUse` / `PostToolUse` / `PostToolUseFailure` into **`executeToolCall`**, `UserPromptSubmit` and `Stop` into the loop, `PreCompact` / `PostCompact` into **`CompactSession`**, `SubagentStart` / `SubagentStop` into the spawn runtime and `Notification` into the permission gate; **`internal/session/hooks.go`** fires `SessionStart` from the manager and keeps its context on the session. **`hooktest`** re-executes a test binary as the hook process. Guide: **`docs/features/hooks.md`**, design: **`docs/plans/hooks.md`**. |
| `internal/session` | Session manager, Filesystem persistence, Acp hooks, rules catalog, the **paged reads** of a history (**`message_window.go`**: **`PageMessages`** step-aligned pages and the counts a client numbers them from, **`MessagePage.UILog`**; behind **`GET /coddy/sessions/{id}/messages?limit=&before=&from=`**, issue #338), and the **provider usage** cache and schedule (**`provider_usage.go`**: the account quota behind a session's model, published as **`provider_usage`** at session ready and from every turn's release, served over **`GET /coddy/providers/{name}/usage`** and the events stream; **`provider_usage_sources.go`** picks the reader per provider type - **`internal/llm/neuraldeep_usage.go`** for the hub's `GET /v1/limits`, **`codex_usage.go`** for the Codex backend's usage endpoint, **`devin_usage.go`** for the seat-management status RPC), and the **tool-call pairing** guard (**`tool_pairing.go`**: **`ValidateToolPairing`** checks assistant batches against their contiguous tool results, **`RepairMissingToolResults`** inserts stable synthetic results for unambiguous missing calls on the outbound projection only - the stored transcript is never rewritten; the agent refuses a still-malformed history at the send boundary). Design: **`docs/plans/neuraldeep-usage.md`**. |
| `internal/mention` | The one grammar of **`@`** mentions (**`grammar.go`**: relative, absolute, **`~`** and **`../`** paths, quoted paths, **`:N-M`** / **`#L`** ranges, folders, **`@session:`** / **`@rule:`** / **`@agent:`** / **`@coddy:`**, web pages; every reading of an ambiguous token, the one that exists winning), path resolution (**`paths.go`**), the workspace index for completion (**`index.go`**: **`git ls-files`** or a walk, cached per root, rebuilt when a mention starts), fuzzy ranking (**`fuzzy.go`**) and the **`<coddy_attachment>`** element plus its display twin **`ForDisplay`** (**`attachment.go`**). **`internal/session/mentions.go`** resolves a prompt's mentions once, into that user message; **`mention_search.go`** answers completion (**`GET /coddy/mentions`**, the console in-process); **`mention_check.go`** runs the resolver dry for the web composer's highlight (**`POST /coddy/mentions/check`**), so only a mention sending would attach is marked; **`internal/agent/mentions.go`** adds the rules a mentioned path activates to the same message, so a mention never moves the system prompt. The SPA twin is **`external/ui/src/ui/skills/draftAt.ts`**, held to **`internal/mention/testdata/grammar_cases.json`** like the Go side. Guide: **`docs/features/mentions.md`**. |
| `internal/docs`, `docs/embed.go` | The documentation built into the binary: **`docs/embed.go`** (package **`docs`**, the only place the embed pattern reaches the tree from) carries **`nav.yaml`** and the pages of its groups, and the Russian translation under **`ru/`** (its **`nav.yaml`** and the same pages), never **`plans/`** or **`assets/`**; **`internal/docs`** reads them into a **`Library`** per language (**`library.go`**: **`Default`** (English), **`LoadLang`**, **`Resolve`** for every spelling of a page, **`Prev`** / **`Next`**; **`lang.go`**: **`For(lang)`**, **`Lang`** for any spelling of a locale, **`LangFromEnv`** (`CODDY_LANG`, `LC_ALL`, `LC_MESSAGES`, `LANG`), **`LangOfText`**; a translated heading answers to the anchor of the English heading at the same position, so an address names a section in both languages), splits pages at headings with GitHub's anchors (**`markdown.go`**: **`Anchor`** and **`HeadingAnchors`**, which **`internal/docsgen`**'s link check uses too, and the link rewriting to **`coddy:<slug>#<anchor>`** and to GitHub at the release tag), ranks sections with BM25F (**`search.go`**, index built once on first use per language; Russian words stemmed by Snowball, **`stem_ru.go`**) and reads a page or a section under a budget of characters (**`read.go`**). Consumers: **`coddy_docs_search`** / **`coddy_docs_read`** (**`internal/tools`**), **`@coddy:`** (**`internal/session/mentions.go`**, **`mention_search.go`**), **`GET /coddy/docs*`** (**`external/httpserver/docs_http.go`**), the web reader (**`external/ui/src/ui/docs/`**), the console's F1 help (**`external/cli/docs_modal.go`**), **`coddy docs`** (**`cmd/coddy/docs.go`**). A new group folder under **`docs/`** goes into the embed pattern; **`TestDefaultCarriesEveryPageOfTheMap`** fails otherwise. Guide: **`docs/features/built-in-docs.md`**. |
| `internal/rules` | Rules: discovery (**`factory.go`**) reads **one** project folder, the first of `.coddy/rules`, the shared `.agents/rules` as system **`agents-dir`**, `.cursor/rules`, `.claude/rules` and `.codex/rules` that holds a rule file - so another agent's mirror of the same rules is never loaded next to it - plus the operator's own **`${CODDY_HOME}/rules`** as system **`user`**, which applies in every workspace and loses a name clash to the project; nested `AGENTS.md` and `DESIGN.md` files read on demand for the folders a tool enters and never walked for (**`agents.go`**, **`AgentsForPaths`**), the dialect chosen by extension in **`markdown.go`** (**`.mdc`** Cursor: `description` / `globs` / `alwaysApply`, default manual; **`.md`** Claude Code: `paths`, unconditional without them) with a lenient reader for Cursor's YAML-invalid `globs: **/*.go` headers, doublestar glob matching anchored at the session cwd (**`MatchGlob`**), activation from mentioned paths (`@path`, an editor's `file://` attachment) and filesystem tool paths (**`select.go`**, **`scope.go`**): only the always-on rules (**`AlwaysOnRules`**) are in **`{{.Rules}}`**, a rule a tool call activates rides in that call's result (**`llm.Message.Rules`**, **`internal/agent/rules_activation.go`**) and one a mention brings in that user message, each once until a compaction folds it away, and the catalog behind **`coddy rules list`**, which names the folder read and the ones skipped. The **`AGENTS.md`** and **`DESIGN.md`** documents come in three layers, always and in this order, whatever `rules.auto_discover`, `rules.systems` or the prompt template say (issue #425): the agent home's pair, then the workspace's (**`project_docs.go`**, **`LoadStanding`**), both in the system prompt, then the pair of every folder on the way down to a file a tool or a mention reaches (**`AgentsForPaths`**), attached to that result or message; no config key names them - the file existing is the switch. **`instructions.files`** (empty by default) only adds files after them, each under a `### <path>` heading like the documents, its entries resolved by **`internal/session/instructions_load.go`**, which also reports the entries whose file a session cannot read (**`UnreadInstructionFiles`**: a log warning per rules generation, the console header, **`--dry-run`**) - one naming the same file in every workspace (absolute, `~`, `${CODDY_HOME}`) whenever it is unreadable, a relative or `${CWD}` one only when its file is there and unreadable - since **`LoadStanding`** skips such a file without a word. Every file reaches the model once per rules generation, known by its real path (**`dockey.go`**, **`DocKey`**: links resolved, case folded on Windows and macOS), the first occurrence winning, so a nested document the system prompt already carries is not attached again. The standing parts are rendered once per rules generation of the session (**`session.RulesPrompt`**, **`internal/agent/rules_prompt.go`**) and placed in whatever template a turn runs on by **`internal/agent/system_prompt.go`** (**`renderWithDocuments`**: documents a template did not print are appended), so an `AGENTS.md` edited mid-session does not move the cached system prompt; a compaction, a config reload or a workspace switch starts the next generation. Guide: **`docs/features/rules.md`**. |
| `external/httpserver` | The HTTP surface **`coddy serve`** runs under **`httpserver.enable`**, built with **`tags=http`** (SSE bridge, Swagger statics, `/coddy` REST, ServeMux wiring). **`Serve(ctx, Options)`** takes an already-built manager; **`turn_mirror.go`** implements **`session.TurnMirror`**, publishing a turn another surface started into that session's composer relay so a browser can watch it - session updates fan out, permission and question requests stay with the surface that has a human. **`status_http.go`** serves **`GET /coddy/status`** (registered only when **`Options.Status`** is set, so a server without it answers 404 like an older one) and merges its OpenAPI schemas. **`subagent_permission_hub.go`** is this server's offer to **`serve.Runtime`** for detached permission prompts: a subagent whose spawning turn has ended publishes its prompt on the agent task row (**`pending_permission`**, shown in the parent chat) and on **`GET /coddy/events`** (**`subagent_permission`**), answered through the ordinary **`POST /coddy/sessions/{child}/permission`**. |
| `external/ui` | Embedded SPA (`go:embed`) when built with **`tags=http,ui`**. **Settings → System** (**`src/ui/settings/SystemSection.tsx`**, a lazy chunk, with **`systemStatus.ts`** and **`Sparkline.tsx`**; the reads and the badge in **`systemStatusApi.ts`**) draws **`GET /coddy/status`** of the server the page talks to (**`DESIGN.md`**, *System tab*). It installs as an app and shows system notifications from the background: the manifest, icons and service worker in **`public/`**, the notifications in **`src/ui/pwa/`** (**`DESIGN.md`**, *Installable app and notifications*). Everything Telegram-specific is **`src/ui/telegram/`** (the Mini App: its own bridge to the client, no script from telegram.org; **`DESIGN.md`**, *Telegram Mini App*). A long conversation is a sliding window: the newest page first, older pages as the reader scrolls up, a bounded slice of rows in the DOM (**`chat/TranscriptList.tsx`**, **`useTranscriptWindow`**; **`DESIGN.md`**, *Transcript window*); **`npm run check:transcript`** measures it in a real browser against the binary. The views of a chat are buttons in the chat header: Files as an icon with a short name on a desktop and a tablet, Background tasks at the right edge with its dot and counts; the dock shows the background tasks without a tab strip, the edits open in a window of their own (git's report of the folder, opened from git's count at the right edge of the plate joined to the top of the composer card, **`chat/WorkspaceBar.tsx`**), and so do the files, each with nothing over it: source coloured as one text with the chat's grammars, pictures, sound and film, a PDF in the browser's own viewer, an SVG as a picture and an HTML page in a sandboxed frame through **Preview** in the window's menu (**`src/ui/files/FilesView.tsx`**; **`DESIGN.md`**, *Views of a chat*, *Workspace files window*); **`npm run check:files`** drives them through an authenticated swarm relay. |
| `external/memory` | Long-term memory (**`-tags memory`**): the memory subagent's prompt template, task and tool set (**`agent.go`**), the note storage and the six **`coddy_memory_*`** tools. The run itself is launched by **`internal/agent/memory_hooks.go`** as a system child in the task pool (bookkeeping in **`memory_run.go`**, untagged): one run per user turn, a bounded wait for its report, delivery in the turn context block after the history (never the system message, so the provider's prompt cache holds), retention of finished runs. See README there and **`docs/features/memory.md`**. |
| `external/cli` | Interactive console TUI (**`-tags cli`**): bare **`coddy`** on a terminal, pi-style rendering in **`external/cli/tui`**. Guide: **`docs/surfaces/console.md`**. |
| `external/swarm` | The relay **`coddy serve`** runs under **`swarm.enable`**, built with **`tags=swarm`** (part of **`FULL_TAGS`**, so the release binaries, image and packages carry it): the stateless relay - lease registry, per-node mount, aggregated sessions, ring-aware topology, and the reverse HTTP/2 tunnel for nodes that can only dial out - plus the relay's own settings (**`settings.go`**: **`/coddy/config*`** over **`internal/configapi`** with the relay's form, **`config.RelayUISchemaJSON`**, the document scoped to **`swarm`** and **`logger`** so a relay client neither reads nor writes the rest of the host's configuration, and a write through a mount refused when the node behind it is itself a relay). It serves nothing else of an agent: no **`/v1`**, no sessions, no documentation. **`Server.Close`** ends the tunnels, so a relay rebuilt on a config reload is dialled again. While it runs, **`Serve`** publishes its registry (**`swarm.PublishRelay`**) for the status snapshot. Shared vocabulary and the join client live untagged in **`internal/swarm`**, so **`coddy serve`** can join a relay without the tag; every running join set registers itself (**`internal/swarm/status.go`**: **`swarm.Joins`**, the relay host, kind, transport and online state of each registration, never a name or credential), and an agent registration yields to the relay of its own process (**`Client.yieldsToRelay`**): a process that runs both its API and a relay starts two join sets from one **`swarm.join`** that share the lease of one name, so the relay registers it, once, and the agent stays quiet until that relay stops. Guide: **`docs/operate/swarm.md`**. |
| `external/metrics` | The Prometheus exporter **`coddy serve`** runs under **`metrics.enable`**, built with **`tags=metrics`** (in **`FULL_TAGS`**, so release binaries, image and packages carry it) and an **auxiliary** subsystem (**`serve.Subsystem.Auxiliary`**: never the only one, always free to stop; **`serve.Resolve`** refuses a config that enables only it). The package has no stub and **`cmd/coddy/metrics.go`** is its only importer; **`metrics_stub.go`** (no tag) keeps the descriptor, so a binary without the tag refuses **`metrics.enable`** naming the tag. **`collector.go`**: one private **`metrics.Set`** per family of **`github.com/VictoriaMetrics/metrics`** (**`expose.go`** writes HELP and TYPE in front of each, escapes label values in the series names and adds the library's Go and process families under a generic HELP), built by **`metricsWiring.Start`** right after **`rt.Init`** whenever the binary has the tag (collection spans the process; **`metrics.enable`** only opens the listener), fed by **`llm.AddCallObserver`** (**`internal/llm/call_observer.go`**: one event per provider attempt, its outcome one of **`llm.CallOutcomes`**, whether it is a retry, and the time to its first chunk with content, which **`Stream`** notes only for a caller's callback while someone observes, since a nil callback decides whether a failed stream may be retried), **`agent.AddToolCallObserver`** (**`internal/agent/tool_observer.go`**: one event per tool call, named by the built-in tool, **`mcp`** or **`other`** and never by an MCP server or tool, its outcome **`ok`**, **`error`**, **`refused`** or **`denied`**), **`session.Manager.AddTurnObserver`** (the turn edges and the duration between them) and **`Runtime.AddConfigObserver`** (a reload that leaves an exporter off loopback without a token is warned about), with the label budget of the llm families (16 providers and 64 named provider and model pairs for the life of the process, everything else **`other`**, decided when the attempt is observed, so labels are closed enums and configured names only; every admitted pair is seeded at zero); **`expose.go`** also writes the gauges from one **`serve.Snapshot`** per scrape, so a scrape asks no provider or server anything; every 15 s the **`Collector`** samples its own instrument sets the way a scrape writes them into a one-hour ring (**`Metrics`**: the providers and seven series of the status document), with no counter of its own and no scrape slot; **`serve.go`** serves **`GET /metrics`** behind the live bearer token (**`404`** elsewhere, at most two scrapes at once across every listener of the collector), and since the **`Collector`** outlives **`Serve`** and the fingerprint is the listen address (no **`RestartKey`**), a new address rebuilds the listener in place with the counters kept. The Metrics tab of Settings is hidden without the tag (**`config.UISchemaJSONHiding`**, fed by **`httpserver.Options.UnbuiltSections`**). The alert rules and their **`promtool`** cases are **`examples/prometheus/`**, the Grafana dashboard and a Prometheus and Grafana compose stand **`examples/grafana/`** (**`TestTheDashboardAndTheRulesReadOnlyExportedSeries`** holds both to what a scrape holds). A relay process runs an exporter of its own and does not carry its nodes'. Guide: **`docs/operate/monitoring.md`**. |
| `internal/configapi` | The settings editor's four routes (**`GET /coddy/config/schema`**, **`GET`** / **`PUT /coddy/config`**, **`POST /coddy/config/validate`**) over a **`Backend`** - the live configuration, how a new one is installed, which schema and document decoration go with it - so the HTTP server and the swarm relay share one PUT transaction under **`config.WithConfigFileLock`**, **`Sections`** to serve and take only some top-level sections (the relay's), and **`Revisions`**, the last 32 configurations a process served, which a **`PUT`** is measured against. Untagged. |
| `external/gateway` | Messenger gateway (**`-tags gateway.telegram`**, **`-tags gateway.pachca`** or **`-tags gateway`** for both), each bot a subsystem of **`coddy serve`** of its own: the Telegram bot under **`gateways.telegram.enable`** (a photo, a document or an album a person sends it reaches the session as the web UI's attachments do, downloaded through the Bot API with the caption as the prompt: **`telegram/media.go`**, issue #509), and the **Pachca** integration bot under **`gateways.pachca.enable`** (**`external/gateway/pachca`**: reads the bot's events history - newest first down to a `(created_at, id)` watermark in `gateway_pachca_state.json`, dispatched oldest first, deleted after handling - answers every message of a direct chat and, in a group, only a mention or a reply to the bot (Telegram follows the same group rule), streams into one edited message, answers a message with no text (a file on its own) that it reads text only; a reply reaches the session with the replied-to message quoted in front (**`external/gateway/replyquote`**, both adapters); its offline fake is the untagged **`internal/pachcafake`**, guide **`docs/surfaces/pachca.md`**); session store, proxy support, the ACL over **`access.Policy`** shared by both, and the web UI as the Telegram bot's Mini App (**`telegram/miniapp.go`**: the menu button and **`/app`** under **`gateways.telegram.mini_app`**, advertised only behind a credential through **`gateway.Options.WebUI`**, which **`cmd/coddy/serve.go`** decides, and opened only by the bot's admins, whom the launch data signs in (**`POST /coddy/auth/telegram`**, **`external/httpserver/auth_telegram.go`**); a sender who is not in a bot's **`admins`** only chats: the turn carries **`access.NonAdminTurn()`** (**`session.TurnRestriction`**: config, model, subagent, worktree and scheduler tools refused, approvals always asked and refused by the bot); **`gateway.Fingerprint`** and **`gateway.PachcaFingerprint`** in the untagged **`serve.go`** hash each bot's whole block). Chat turns run through the shared manager and are mirrored into the HTTP relay, so a conversation is live in the web UI; what the messenger needs is said twice and kept out of the transcript both times - a system prompt block for the turn (**`prompt.go`**, carried by **`session.PromptRunOpts.SurfaceSystemPrompt`**) and the outbound rendering of the answer (**`markdown.go`**); the poller stops intake before generation on shutdown, so a restart from a settings change does not truncate an answer. Full guide: **`docs/surfaces/gateway.md`**, rules: **`.cursor/rules/gateway.mdc`**. |
| tgfake (external module) | The offline stand for the Telegram gateway lives in its own repository, **[github.com/EvilFreelancer/tgfake](https://github.com/EvilFreelancer/tgfake)** ("tgfake server"): a fake Bot API (every method the adapter calls, long-polled `getUpdates`, the `/sim/*` simulation API, a chat page that also plays Telegram for a Mini App) plus a scripted OpenAI-compatible model behind **`--llm`**. **`go.mod`** pins one version and everything here uses that one: the gateway's Go tests import **`pkg/server`** (as `tgfake`, through **`external/gateway/telegram/fakeapi_test.go`**) and **`pkg/botapi`**, several suites import **`pkg/llmstub`** (with **`StripTags: ["turn_context"]`**), the scripts build **`github.com/EvilFreelancer/tgfake/cmd/tgfake`** (a **`tool`** of **`go.mod`**) unless **`CODDY_TGFAKE_BIN`** names a prebuilt binary, and CI's **Telegram stand (tgfake)** job installs the release of that version with tgfake's own action. **`coddy serve`** is pointed at it with **`CODDY_TELEGRAM_API_BASE`** (**`config.TelegramAPIBaseEnv`**, honoured by the bot and the **`--dry-run`** probe alike). A change Telegram's behaviour needs is made in tgfake, released, and bumped here. Runner: **`examples/gateway/tg_e2e_offline.sh`**. Guides: **`docs/contributing/tgfake.md`** (running and bumping it), **`docs/surfaces/gateway.md`** (Debugging against a fake Bot API). |
| `packaging` | Distribution package sources: the **nfpm** recipe (**`nfpm.yaml`**) for the Linux **`.deb`** and **`.rpm`**, the **systemd user unit** (**`systemd/coddy.service`**, installed and not enabled, rendered by **`serve.PackagedUnitFile()`** and held to it by a test), the Homebrew **cask** template (**`homebrew/coddy.rb.tmpl`**, prebuilt macOS archives; every release commits the rendered cask to the tap **coddy-project/homebrew-coddy-agent** with **`scripts/publish-homebrew-tap.sh`**, and the **Homebrew tap** workflow, **`.github/workflows/homebrew.yaml`**, installs it with a real brew on macOS through **`scripts/check-homebrew-cask.sh`**) and **formula** template (**`homebrew/coddy-formula.rb.tmpl`**, built from source - the artefact **homebrew/core** takes), the man page (**`man/coddy.1`**), bash and zsh completions, and the maintainer scripts (**`scripts/postinstall.sh`**, **`scripts/preremove.sh`**: what the user has to do next, the unit not enabled included). The package enables nothing and creates **no system service, system account or `/etc` files**; Coddy's state lives in the invoking user's **`~/.coddy`**, and **`coddy serve install`** (**`internal/serve/systemd.go`**) enables the unit for one account, or writes one under **`~/.config/systemd/user`** for a binary the install script put in place. Built by **`scripts/build-packages.sh`**, **`scripts/build-homebrew-cask.sh`** and **`scripts/build-homebrew-formula.sh`** (**`make deb`**, **`make rpm`**, **`make brew`**, **`make brew-formula`**; **`make brew-publish`** and **`make brew-cask-check`** for the tap); **`scripts/check-homebrew-submission.sh`** (**`make brew-check`**) is the preflight for the homebrew/core submission. Guides: **`docs/getting-started/install.md`**, **`docs/getting-started/homebrew.md`** (which Homebrew repository takes what, and the submission path), **`docs/contributing/build.md`** (Distribution packages), **`docs/getting-started/update.md`** (installations owned by a package manager). |
| `internal/docsgen`, `cmd/docsgen` | The documentation generator and checker behind **`make docs`** / **`make docs-check`** / **`make docs-stamp`**: the hub and the llms files from **`docs/nav.yaml`** (and the Russian ones from **`docs/ru/nav.yaml`**), the translation checks and the stamp of **`docs/ru/`** (**`translate.go`**), the link localization of a translated page (**`localize.go`**), the config field tables from the schema and **`config.DocDefaults`**, the CLI reference from the `--help` screens, the assets inventory, the relative-link and anchor check. Every tree walk asks git what it excludes and skips it (**`gitignore.go`**), so local scratch under **`docs/`** is never reported as a page missing from the map; without git nothing is filtered. |
## Builds
**`coddy serve`** is the only long-running command: it starts every subsystem **`config.yaml`** enables (**`httpserver.enable`**, default true; **`gateways.telegram.enable`**; **`gateways.pachca.enable`**; **`swarm.enable`**; **`scheduler.enable`**; **`metrics.enable`**, auxiliary: it serves beside the others and never keeps the process up on its own) over one session manager. A subsystem enabled but not compiled in is a startup error naming the tag. Run **`make build TAGS=http`** for the HTTP surface only (REST and **`/docs`**, no **npm**). Run **`make build TAGS=cli`** for the interactive console (**bare `coddy`** on a terminal; see **`docs/surfaces/console.md`**). Run **`make build TAGS="http ui"`** to link the embedded SPA (**Makefile** runs **ui-build** before **go build**). Recommended full image matches **`Dockerfile`** (**`make build TAGS="http ui scheduler memory cli gateway swarm metrics"`**). Default **`make build`** omits HTTPServer, scheduler, and memory to keep dependency surface lean.
Primary conversational surface for bundled UI lives at **`POST /v1/responses`** with **`stream:true`**. Prefer it over **`POST /v1/chat/completions`** when shipping Coddy-hosted experiences.
Run **`make android`** for the Android (Termux) binaries, **`build/coddy-android-arm64`** and **`build/coddy-android-amd64`**: **`GOOS=android`** with cgo, linked by the Android NDK against Bionic (**`scripts/android-cc.sh`** finds the NDK), a position-independent executable naming **`/system/bin/linker64`**, which Termux can start where it runs programs through the system linker (the static **`GOOS=linux`** build is refused there with `has unexpected e_type: 2`). The release publishes them as **`coddy_X.Y.Z_android_arm64.tar.gz`** and **`coddy_X.Y.Z_android_amd64.tar.gz`**; a build without cgo does not compile (**`internal/platform/android_nocgo.go`**). What Termux patches into its own Go is done at run time by **`internal/platform/android*.go`** (the binary's own path under the linker, certificates, **`TMPDIR`**), and every **`exec.Cmd`** goes through **`platform.AdaptCommand`** before **`Start`** (held by **`TestEverySpawnSiteAdaptsTheCommand`**). Guide: **`docs/getting-started/android.md`**.
Run **`make deb`** / **`make rpm`** for the Linux packages (**`PKG_ARCHS`**, **`PKG_TAGS`**, **`DIST_DIR`** knobs; nfpm is fetched on demand, nothing to install) and **`make brew VERSION=X.Y.Z`** to render the Homebrew cask for a release. CI builds the Linux packages on every pull request.
Run **`make check-image`** to build the runtime image for **`linux/amd64`** and **`linux/arm64`** and check that each variant carries a **`/bin/coddy`** of its own platform (**`scripts/check-image.sh`** reads the ELF header; Docker with buildx is all it needs, the build stages cross-compile and nothing runs under emulation). The **Docker image** job runs it on every pull request and the release workflow runs it before it pushes. Guide: **`docs/getting-started/docker.md`** (Checking the platforms of the image).
Swagger lives at **`/docs/`**, OpenAPI YAML at **`/openapi.yaml`**.
## Pre-commit gate
A git **`pre-commit`** hook runs the linter before every commit, so nothing lands with lint errors. It is the single enforcement point for humans and coding agents alike. Tests are **opt-in** on commit: **`make test`** (the express run: the SPA's vitest suite, then every optional module compiled in once) belongs before the push, and the per-combination tag matrix (**`make test-matrix`**) runs on GitHub Actions for every pull request, as does **`make test-race`**: the race detector over the whole tree with every tag but `ui`, clean in every package. Run it before the push when a change touches goroutines, locks or a test harness that drives a live loop. Two groups run on their own: **`make test-cache`** (the **`features/prompt_cache_*.feature`** specs and the **`TestPromptCache*`** tests, part of `make test`: what each request sends the provider, byte for byte across a session - run it when a change touches the system prompt, the rules, the history or the send boundary) and **`make test-perf`** (the benchmarks of the **`perf_test.go`** files, never part of `make test`: timings and the context a session costs, as token metrics).
- Enable once per clone: **`make hooks`** (sets **`core.hooksPath=.githooks`**; this is local config and is not committed, so every clone runs it once).
- AppSec scanning is **opt-in** the same way: **`make security`** (trivy + semgrep + govulncheck over the checkout, docker fallback when the binaries are absent; govulncheck fails on a vulnerable function the code calls, the standard library of the `toolchain` line of **`go.mod`** included) is what `.github/workflows/security.yaml` runs on pull requests, pushes to main and a weekly cron; **`make sec-report`** scans without failing on findings. Reports and the severity summary land in **`dist/security/`**; the gate knobs (`SEC_FAIL_TRIVY`, `SEC_FAIL_SEMGREP`, `SEC_FAIL_GOVULNCHECK`, `GOVULNCHECK_TAGS`, `SEC_DOCKER`, `SEMGREP_CONFIGS`) and the triage workflow (`.trivyignore.yaml`, `trivy.yaml` skip-dirs, `nosemgrep`) are in **`docs/contributing/security-scanning.md`**.
- On commit, **`.githooks/pre-commit`** calls **`scripts/checks.sh`**, which runs **`make lint`** by default: **`make fmt-check`** (`gofmt -s -l` over every Go file git tracks, whatever its build tags or platform), `golangci-lint` over the untagged tree, over every tag but `ui` and over the shipped tag set, so every Go file of the host platform but the `http && !<tag>` stubs of **`external/httpserver`** is compiled once (**`.golangci.yml`** enables the `gofmt -s` formatter there too), then `tsc --noEmit` over the SPA sources (**`make ui-typecheck`**); then, when the commit stages SPA sources, Prettier over the whole SPA (**`make ui-format-check`**, `prettier --check .` in **`external/ui`**, **`.prettierignore`** keeping the build output and the vendored grammars out), which CI's Lint job runs too: the tree is Prettier-clean, and **`npm run fmt`** in **`external/ui`** keeps it so. Commits touching only non-code files (docs, etc.) skip the gate.
- Scope knobs: **`CODDY_HOOK_TESTS=fast`** also runs a quick **`go test ./...`**, **`CODDY_HOOK_TESTS=full`** the express **`make test`**, **`CODDY_HOOK_TESTS=matrix`** every tag combination; **`CODDY_HOOK_LINT=0`** skips the linter; **`CODDY_HOOK_FORMAT=0`** skips the Prettier check; **`CODDY_HOOK_SKIP=1`** bypasses everything.
- Emergency bypass for a single commit: **`git commit --no-verify`**.
- The Android half of **`internal/platform`** (**`//go:build android`**) is compiled by neither gate either: **`make check-android`** runs **`go vet`** with **`GOOS=android`** and cgo through the Android NDK, test files included, and CI runs it in the **Android cross-build** job, which also uploads both release-shaped binaries as artifacts.
- Both gates compile the **host** platform only. Changing a file behind **`//go:build windows`**, or a signature it shares with the rest of the tree, needs **`make check-windows`** (cross-build plus **`go vet`** over every non-**`ui`** tag combination, test files included) and **`make lint-windows`**. CI runs both, and additionally runs **`go test`** on a real **`windows-latest`** runner for **`internal/platform`**, **`internal/bgtask`**, **`internal/tools/shell`**, **`internal/update`**, **`internal/hooks`**, **`internal/rules`** and the console TUI (**`external/cli`**, `-tags=cli`), plus **`cmd/coddy`**, **`internal/config`**, **`internal/dryrun`** and **`internal/swarm`**, whose tests write files and compare paths on the runner's CRLF checkout. The platform packages and the console suite run on a **`macos-latest`** runner as well (job **`test-macos`**), which also builds the console and drives the binary through a real pty with **`examples/cli/cli_e2e_startup.py`** (first frame, keys, exit); the `cli` job of the Linux test matrix runs that pty script too, and the **Telegram stand (tgfake)** job builds the gateway binary with the exporter (**`TAGS="gateway metrics"`**) and runs **`examples/gateway/tg_e2e_offline.sh`** with **`TG_E2E_METRICS=1`**, which scrapes the exporter after the chat: the real **`coddy serve`** against the fake Bot API and the scripted model of the **tgfake** release binary of the version **`go.mod`** pins, installed by tgfake's own action, no Telegram and no LLM.
## Documentation contract
The documentation is the tree under **`docs/`**, mapped by **`docs/nav.yaml`**: `getting-started/`, `surfaces/`, `operate/`, `features/`, `reference/`, `contributing/`, `tutorials/`, and `docs/assets/` for everything a page embeds. **`docs/plans/`** (design records, never rewritten to match a later rename) is internal and stays out of the map, so the hub and the llms files do not carry it. Anything git ignores under **`docs/`** is not documentation at all and is not walked. **`docs/README.md`** (the hub), the field tables of **`docs/reference/config.md`**, the help screens of **`docs/reference/cli.md`** and the inventory of **`docs/assets/INDEX.md`** are generated by **`internal/docsgen`** (`make docs`), which also builds **`llms.txt`** and **`llms-full.txt`** straight into the site layer - they are a concatenation of every page, so a copy in this index conflicted in every branch that touched any page, and `.gitignore` keeps them out; **`make docs-check`** (the CI job **Documentation**, and the pre-commit hook for commits that touch the docs) fails on a stale generated block, a page missing from the map, a broken relative link or anchor, and an asset no page uses. **`make docs-changelog`** refreshes **`docs/getting-started/changelog.md`** from the GitHub Releases. The documentation is also in Russian: **`docs/ru/<group>/<page>.md`** translates **`docs/<group>/<page>.md`** under the same path, **`docs/ru/nav.yaml`** and **`docs/ru/config-descriptions.yaml`** translate the map and the schema descriptions, and the binary carries both languages (every surface shows the one it knows the reader uses). A change to an English page carries into its translation in the same pull request and is restamped with **`make docs-stamp PAGES=...`**; **`make docs-check`** fails on a missing or stale translation and on headings that differ from the source. The conventions and the writing rules for both languages are the paired **documentation translation rule** ([Cursor](.cursor/rules/docs-translation.mdc), [Claude Code](.claude/rules/docs-translation.md)). The full guide, page types included, is **`docs/contributing/documentation.md`**.
A change to Coddy carries its documentation in the same pull request (step 10 of the **paired workflow rule** - [Cursor](.cursor/rules/workflow.mdc), [Claude Code](.claude/rules/workflow.md)): a new capability gets its page, its `nav.yaml` entry and its row in the reference of its kind (`tools.md`, `slash-commands.md`, `environment-variables.md`, `keyboard.md`); a changed behaviour updates the page that describes it (`git grep -n '<key or command>' docs/`); a web UI or console feature is shown with a screenshot of the real surface embedded on its page, Dark 1280 wide, named `<feature>-<state>-<theme>-<width>.png` under `docs/assets/<page>/`; a demo video goes to `docs/assets/video/` and is embedded on the page as a GitHub attachment URL with the repository copy linked under it (Videos in `docs/contributing/documentation.md`). Add screenshots under `docs/assets/` only when they document a new UI/UX feature or a changed existing UI/UX feature, and embed every such asset on its documentation page. Keep pull-request-only evidence out of `docs/assets`: publish it on the `screenshots` branch of this repository (`coddy-project/coddy-agent`, never a personal fork) under a pull-request-specific directory, then embed its raw GitHub URLs in the pull request description. `make docs-check` governs every asset committed under `docs/assets/`. A page that moves takes its address with it: there are no redirect stubs. Every page of the map also has a stable address on **coddy.dev**: `coddy.dev/docs/<slug>` sends a person to the page on GitHub and `coddy.dev/docs/<slug>.md` to its raw Markdown (one generated script behind the site's `404.html`, nothing duplicated), while `llms.txt` and `llms-full.txt` at the site root index the raw Markdown on `main`; the binary, the schema and the bundled skill print those addresses and nothing else. **`make site-docs`** renders the layer into the site checkout and **`make site-docs-check`** reports drift - a change to a page, to `nav.yaml` or to the schema is published to the site with the same pull request (workflow step 8).
Human prose for HTTP lives in **`docs/reference/http-api.md`**. Visual spec for SPA lives in **`DESIGN.md`** (this repo root). Architectural narrative remains under **`docs/contributing/architecture.md`**.
**`internal/config/config.schema.json`** is embedded into the binary (`go:embed` in **`internal/config/schema.go`**: it is what **`-t` / `--test-config`** validates a file against, its `description` strings are the `doc:` lines of that report and the Description column of the generated **`docs/reference/config.md`** tables) and leaves this repository: it is served at **`https://coddy.dev/config.schema.json`**, the address Coddy writes as a modeline into every config it saves, out of the site repository **`coddy-project.github.io`** as a verbatim copy. Touching the YAML config surface means **`make docs`** and **`make site-schema`** (and **`make site-schema-check`** to see whether the published copy is stale) - a schema that stops here leaves every editor validating against one the binary no longer matches. Hold the site push until the release when a key was renamed or removed: the schema sets **`additionalProperties: false`**, so the new file marks the old key as an error in configs already on disk. Full step: the **paired workflow rule**.
All **code comments** plus **technical markdown authored for this repo** (including `docs/`, `DESIGN.md`, `AGENTS.md`) stay **English** unless an operator explicitly asks for another natural language.
## Prose skills
Prose this repository publishes - the pages under **`docs/`** in both languages, **`DESIGN.md`**, the dictionaries under **`external/ui/src/ui/i18n/messages/`**, commit messages, pull request and issue text, review replies - is often drafted by a coding agent, and an agent's draft carries the patterns of machine-written text: stock openers, binary contrasts, em dashes, officialese, calques from English in Russian. Whoever works on this repository with an agent installs these four skills and runs a draft through them before it is final:
| Skill | What it checks | Applies to |
| --- | --- | --- |
| [stop-slop](https://github.com/hardikpandya/stop-slop) | the patterns of machine-written prose | any prose |
| [humanizer-ru](https://github.com/ilyautov/humanizer-ru) | the same for Russian: officialese, calques, filler, translationese | Russian text |
| [ru-text](https://github.com/talkstream/ru-text) | Russian info style, editing and interface copy | Russian text, the `ru` dictionary |
| [logika](https://github.com/EvilFreelancer/logika) | whether the claims of a text agree with each other and follow from what it says | any text that explains or argues |
The universal skills CLI installs each one where the agents on the machine read skills:
```bash
npx skills add hardikpandya/stop-slop
npx skills add ilyautov/humanizer-ru
npx skills add talkstream/ru-text
npx skills add EvilFreelancer/logika
```
Without it, clone each repository into your agent's skill folder under the skill's name: `~/.claude/skills/<name>` (Claude Code), `~/.codex/skills/<name>` (Codex), `~/.cursor/skills/<name>` (Cursor), `~/.agents/skills/<name>` or `${CODDY_HOME:-~/.coddy}/skills/<name>` (Coddy). Coddy also installs logika by itself with **`coddy skills sync`**, from the rpa-skills catalog it ships as a built-in source.
How they apply here:
- write the draft first, then run it through the skills; humanize only what the agent wrote, never a quotation, the operator's own wording or literal text (code, keys, commands, the labels a page takes from the dictionaries);
- documentation and interface copy are technical text for humanizer-ru: fix the critical patterns and the calques, keep the structure, the terms and the format;
- where a skill and this repository disagree, the repository wins: typography follows the paired **documentation translation rule** (a hyphen with spaces, never an em or en dash; straight quotes, never the guillemets ru-text would put in), the glossary there and the paired **Russian wording rule** hold, and a translation still says what its English page says.
## Agent host integrations
**`AGENTS.md`** is the repository-wide baseline, and **`CLAUDE.md`** remains its compatibility symlink. Cursor and Claude Code use paired native rule trees under **`.cursor/rules/`** and **`.claude/rules/`**. Their bodies and activation intent stay equivalent, but neither vendor tree is a universal policy format.
Codex, OpenCode and ZCode use repository adapters that read **`.cursor/rules/*.mdc`**. The Cursor tree is the adapters' input as well as Cursor's native representation; it is not the conceptual owner of project policy. Run **`make test-agent-rules`** after changing an adapter or rule frontmatter.
A scoped model rule is context, not an enforcement boundary. Hooks and plugins can be untrusted, disabled, fail open or miss an opaque write, so critical trust, security and destructive-operation constraints stay in root policy, code, permissions, tests or CI.
### Codex
Codex resolves its `AGENTS.md` chain once per session, walking from the repository root down to the launch directory, so a session started at the root never discovers nested instructions later. The project hook closes the scoped-rule gap for the events and payloads it understands:
- **`.codex/hooks.json`** wires **`.codex/hooks/attach_rules.py`** to `SessionStart` and to `PreToolUse` on `apply_patch` / `Edit` / `Write`;
- session start attaches every `alwaysApply: true` rule;
- pre-tool use extracts patch headers and structured path fields, then attaches unsent scoped rules whose scalar or YAML-list `globs` match;
- the hook parses `.mdc` frontmatter directly, needs no manually maintained index and fails open.
Codex requires explicit approval for hooks and tracks it by content hash. Run **`/hooks`** once per clone and again after either hook file changes. Project-local hooks also require a trusted `.codex/` layer. The full guide and manual probes are in **`docs/contributing/codex-hooks.md`**.
### OpenCode
The project plugin **`.opencode/plugins/project-rules.js`** injects always-on and active scoped rules into model requests, accepts scalar and YAML-list `globs`, and preserves active rules through compaction. When a write tool first reveals a scoped rule, the plugin rejects that call once so OpenCode retries with the rule in context. See **`docs/contributing/opencode-hooks.md`**.
### ZCode
**`.zcode/config.json`** wires **`.zcode/hooks/attach_rules.py`** to `SessionStart` and `PreToolUse` on `Edit` / `Write` / `MultiEdit` / `ApplyPatch`. The behavior matches the Codex adapter, but ZCode supplies edited paths as structured JSON fields. Configuration-file hooks run because the committed config sets `hooks.enabled: true`; there is no per-hook trust prompt. The command uses `python` rather than the Microsoft Store `python3` stub on Windows. See **`docs/contributing/zcode-hooks.md`**.
## Code review invariants
Every reviewer applies this section to changed files. Keep entries behavioral and repository-specific; formatting and lint stay in CI, where the pre-commit gate already runs **`make lint`**.
### HTTP surface
- Do not change routes, request or response shapes, or status codes in `external/httpserver/server.go` without updating `external/httpserver/openapi.go` in the same change. The served spec is what `/docs/` and generated clients consume, so drift is a silent API break. Safe path: update both, then reconcile `docs/reference/http-api.md`.
### Provider transport
- Do not add a provider request path that can bypass `providers[].proxy`. Completions, streams, model discovery, usage, OAuth/device sign-in, refresh and revoke all build through `llm.HTTPClientForProviderProxy`; a test-only explicit client may be accepted, but `nil` must return an error rather than fall back to `http.DefaultClient`. When a Settings sign-in starts before Save, apply the form's current proxy after resolving the row. Safe path: extend `features/provider_proxy.feature`, keep `TestProviderProxyGuard` green, and cover the request class through the proxy harness.
### Build tags
- Do not let a package that builds by default import one that lives behind the `http`, `ui`, `scheduler`, `memory`, or `gateway` tags. Plain `make build` must keep compiling with the lean dependency set. Safe path: put the new code behind the same tag, or invert the dependency into an interface owned by the core package.
### Project-local configuration
- Do not read, merge, or execute project-local configuration (`.coddy/mcp.json`, MCP server definitions, hook scripts) without routing it through `TrustGate` in `internal/mcp`. Opening an untrusted checkout must not by itself grant code execution. Safe path: gate the read on the workspace trust decision and persist the approval through `TrustStore`.
- The same holds for project-scope subagent definitions (`.agents/agents` and `.coddy/agents` under the workspace, or any `subagents.dirs` entry there): they load and spawn only as `subagents.project_trust` allows, a receipt in `subagents.TrustStore` is bound to the file digest, and a definition must never widen what the parent could do - `permission_mode`, `tools` and `disallowed_tools` only narrow, and the mandatory exclusions stay. Safe path: resolve the definition once, decide trust on that value with `subagents.Decide`, and derive the child's tool set with `subagents.EffectiveTools`.
- The same holds for project scheduler jobs (`<workspace>/.coddy/scheduler/*.md`): one runs, on cron or by hand, only as `scheduler.project_trust` allows, and a receipt in `<home>/scheduler-trust.json` is bound to the project (`mcp.TrustWorkspace`), the job id and the file digest. Trust is decided on one `storage.JobSnapshot` and that same snapshot is what runs; an edit through Coddy re-issues a receipt only for a job that was trusted before it; no agent tool approves a project job; links under the project folder are refused and nothing the scheduler writes (the `.state` sidecar) lands in the checkout. Safe path: read with `storage.ReadSnapshot`, decide with `schedservice.Decide`, and route mutations through the session's workspace (`resolveSessionCWD`), never a raw `cwd`. Design: `docs/plans/scheduler-project-jobs.md`.
- A linked git worktree shares what its project decided, never more. Every trust store keys its receipts, and `mcp-overrides.json` its switches, by `mcp.TrustWorkspace`: the canonical workspace, or for a folder of a linked worktree the same folder of its main checkout, which `gitws.MainCheckoutFolder` answers only when the repository, read where its storage really is, names the worktree back, the repository is not bare and the answer stays inside the main checkout once links are resolved, outside any repository nested there (a `.git` file copied into another folder, a `.git` that is a link, an archive that brings storage of its own or a link out of the main checkout lends nothing). It reads only regular files, each up to a small limit, so a FIFO or a device named `.git` never blocks a trust decision. The digest still binds, so what a branch adds or changes asks again, and each checkout keeps its own receipt under the shared key (`Checkout`, `mcp.ReceiptCheckout`): `Approve` replaces only its checkout's receipt, an implicit revoke (a deleted or renamed scheduler job, a dropped marketplace entry) takes only its own, and an explicit untrust takes the project's. A project MCP server that does not name `${CWD}` has one pool entry for the project's checkouts; the scheduler scans projects (`ScanWorkspaces` and the registry go through `mcp.TrustWorkspace`), so a job never runs on cron from a worktree. Paths stay on the real workspace: where a file is read, what a hook file is relative to, where a job file and its state live. Safe path: key a new store by `mcp.TrustWorkspace` inside the store, never by a raw or merely canonical path. Design: `docs/plans/worktree-shared-project-config.md`.
### Packaging
- Do not add, rename or remove a CLI subcommand, a **`serve`** verb or a flag without updating **`packaging/man/coddy.1`** and **`packaging/completions/coddy.bash`** / **`coddy.zsh`** in the same change (and **`topLevelCommands`** in **`cmd/coddy/usage_test.go`** for a command). Every install route ships those files as the documentation of the command set - the release archive through **`install.sh`**, the Homebrew cask and **`coddy update`**, the packages, the formula - and nothing generates them: the test holds the commands and the `serve` verbs to **`printUsage`**, flags are kept in step by hand. Step 11 of the **paired workflow rule**.
- Do not move **`/usr/bin/coddy`** or add a second packaged copy of the binary. **`coddy update`** asks **`dpkg-query -S`** / **`rpm -qf`** who owns the executable it is about to replace, and a packaged file that no database claims would be silently overwritten. Safe path: keep the single **`contents`** entry in **`packaging/nfpm.yaml`**.
- Keep the packaged service an opt-in **systemd user unit** that the package installs and never enables. Do not add a system service, a system account or files under **`/etc`**, and do not enable, start or disable the unit from a maintainer script. Coddy's state is per-user under **`~/.coddy`**, and each user decides with **`coddy serve install`** / **`coddy serve uninstall`**. The unit is rendered by **`serve.PackagedUnitFile()`**: change the renderer and regenerate **`packaging/systemd/coddy.service`**, never the file alone.
- Do not add a file to the release **`.tar.gz`** layout without checking the Homebrew cask: **`packaging/homebrew/coddy.rb.tmpl`** links artefacts out of that archive by name.
- Do not move the man page or the completions inside the repository without updating **`packaging/homebrew/coddy-formula.rb.tmpl`**. The formula installs them from their source paths, and a from-source build is the only packaging route that reads the tree rather than a staged archive. Its **`test do`** block is coupled the same way: it asserts on the output of **`coddy -v`**, **`coddy sessions list`** (the **`SESSION_ID`** header) and **`coddy skills list`** (the **`Search roots:`** line), and once the formula is in homebrew/core those strings are checked by Homebrew's CI, not ours.
- Do not document, script or test a Homebrew install from a URL or a file path (`brew install --cask <URL>`, `brew install --cask ./coddy.rb`): current Homebrew loads a cask only out of a tap and refuses both outside `HOMEBREW_DEVELOPER` mode (issue #269). The route is the tap, `brew install coddy-project/coddy-agent/coddy`. Its **`Casks/coddy.rb`** is generated: change **`packaging/homebrew/coddy.rb.tmpl`**, never the tap, and let the release publish it. A template change is proven by the **Homebrew tap** workflow installing it on macOS with current Homebrew, not by `ruby -c` alone: Homebrew 7 warns on stanzas that still parse (`verified:`, Ruby flight blocks, completions linked as a `binary`), and **`features/homebrew_tap.feature`** holds the rendered cask to that list.
- Do not conflate the two Homebrew artefacts. The **cask** installs prebuilt macOS archives and is upgraded with **`brew upgrade --cask coddy`** out of a **`Caskroom`**; the **formula** builds from source and is upgraded with **`brew upgrade coddy`** out of a **`Cellar`**. **`internal/update`** reads which of the two owns the running executable off its path, so a change to that detection has to keep both hints correct - the wrong flag fails on the user's machine.
### Docker image
- Do not give an automatic platform argument (**`TARGETOS`**, **`TARGETARCH`**, **`TARGETPLATFORM`**, **`BUILDPLATFORM`** and the rest) a default value in the **`Dockerfile`**. A default wins over the value BuildKit passes, and the **`linux/arm64`** image shipped an x86-64 binary that way for months (issue #482): Docker still labels the variant arm64, and a host with **`qemu-user-static`** even runs it. Keep the build stages on **`--platform=$BUILDPLATFORM`** with **`GOOS`** / **`GOARCH`** taken from **`TARGETOS`** / **`TARGETARCH`**, so the Go stage cross-compiles and no step runs under emulation. Safe path: **`features/docker_image_platforms.feature`** (part of **`make test`**, it reads the Dockerfile the way BuildKit resolves those arguments) and **`make check-image`**, which builds the image and reads each binary.
### Documentation
- Do not add a user-visible capability, change what a documentation page describes, or add a tool, a slash command, an environment variable or a key without carrying the change into **`docs/`** in the same pull request: the page under `features/`, `surfaces/` or `operate/`, its entry in **`docs/nav.yaml`**, the reference of its kind, a screenshot on the page when the change is visible in the web UI or the console. **`make docs-check`** catches a missing map entry, a broken link and an unused asset, but it cannot tell that a paragraph went stale - that is what the review is for. Safe path: `git grep -n '<key or command>' docs/` before the change, update every hit, then `make docs`.
- Do not commit screenshots or videos that no page references, and do not put pull request evidence under **`docs/assets/`**. Do not delete or move a page without repairing the links `make docs-check` reports and republishing the site layer (`make site-docs`).
- Do not edit inside a `<!-- docsgen:*:start -->` block by hand; change the source (`nav.yaml`, the schema description, `printUsage`) and run **`make docs`**.
### Embedded UI
- Do not treat a UI change as complete when only `external/ui/src/` moved. The binary serves `go:embed` assets, so unrebuilt sources ship the previous SPA. Safe path: run `make build TAGS="http ui"` and include the regenerated assets in the change.
## HTTP API development flow
When changing behavior for the OpenAI-compatible HTTP gateway or bundled UI:
- Add or update tests first (red), then implement (green). The **happy path** of a feature (and a bug's reproduction) is a Gherkin spec in the repo-root **`features/`** directory, run by a godog harness (e.g. `external/httpserver/bdd_*_test.go`); **edge/error cases go in ordinary unit tests**, not `features/`. See the **paired workflow rule**.
- If the external HTTP surface changes, update `external/httpserver/openapi.go` so the served OpenAPI matches handlers in `external/httpserver/server.go`.
- Keep `docs/reference/http-api.md` aligned with the live behavior.
- For UI changes, update sources under **`external/ui/src/`** and rebuild embedded assets via **`make build TAGS="http ui"`** (runs **npm** via **make ui-build**).
- **Every** UI edit in a PR ships with a screenshot of the surface it changed (before/after when the surface already existed) — see step 5 of the **paired workflow rule**. If a surface cannot be captured, say so in the PR instead of omitting it.
- Run `make test` (the express run over the shipped tag set), then `make lint`. The tag matrix runs in CI on the pull request; do not walk it locally.
## UI sources (`external/ui/`)
**`DESIGN.md`** is the contract for layout, tokens, and SPA component behavior. After changing **`external/ui/src/`**, rebuild embedded assets with **`make build TAGS="http ui"`** before relying on **`go:embed`**.
The composer exposes **`Mode`** (**`agent`** / **`plan`** / **`ask`**) and a separate **`Model`** YAML backend selector (**`metadata.model`**; list rows with **`owned_by`** other than **`coddy`** from **`GET /v1/models`**). A new chat takes the model the cookie **`coddy_llm_model`** remembers when it is still listed, else the alphabetically first YAML row; the row that response marks **`default: true`** (what a session with no selection runs on: **`agent.model`**, or the first **`models[]`** row when it is not listed) is for API clients and the remote console and does not drive the pick. Parallel **`POST /v1/responses`** per session, **Stop** (**cancel** + partial assistant persistence), and transcript merge after **`GET .../messages`** are specified in **`DESIGN.md`** (**Multi-session streaming and Stop**) and **`docs/surfaces/web-ui.md`**.
**`MarkdownLineEditor`** (`external/ui/src/ui/markdown/`) is the shared markdown body editor (line gutter, wrap-aware numbering, active-line highlight, content-driven height). Used in the plan document card and scheduler job body. Visual and behaviour contract: **`DESIGN.md`** (**Markdown line editor**, **Plan mode plan document card**); functional checklist: **`docs/surfaces/web-ui.md`**.
**UI localization** lives in **`external/ui/src/ui/i18n/`** (**`translate`/`t`**, **`I18nProvider`** + **`useT()`**). **`locales.ts`** is the single registry that connects each supported locale to its picker label and dictionary; English and Russian ship today. Add a locale by adding its dictionary and one registry entry — picker options, locale validation, bootstrap, and parity coverage derive from that registry. In Russian copy the adjective for agent/agentic is **агентный**, never **агентский**; a spawned agent is a **субагент**, never **сабагент**; a git worktree stays **worktree**, never **рабочее дерево** (the **paired Russian wording rule** - [Cursor](.cursor/rules/russian-wording.mdc), [Claude Code](.claude/rules/russian-wording.md) - held by **`ruWording.test.ts`**; `git grep -n -i 'агентск'` and `git grep -n -i 'сабагент'` find nothing but the rules that forbid them). When user-facing copy is added or changed on a localized surface, add the key to **every registered dictionary in the same change**; **`messagesParity.test.ts`** enforces key and interpolation-token parity. Counted copy ("N tasks") goes through **`translatePlural`** / **`tp(key, count)`** with one entry per CLDR category (**`key.one`** / **`key.few`** / **`key.many`** / **`key.other`**) so each language declines by number; a locale must carry exactly the categories its own **`Intl.PluralRules`** produces. **`main.tsx`** bootstraps locale (**`?lang=` > cookie > `navigator.language`**) and wraps **`<App/>`** plus the shared confirmation provider in **`<I18nProvider>`**. The language picker is in **Settings → Appearance**, under the theme grid (**`AppearanceLanguagePicker`**); cookie **`coddy_ui_lang`** mirrors **`coddy_ui_theme`** (client-side only, no config save). Appearance, Settings, shared destructive confirmation dialogs, and the conversation surfaces (nav, hero, composer, messages, prompts, plan card, History, scheduler, background tasks, env banner) and the swarm screen (including the topology graph) are translated. Visual contract: **`DESIGN.md`** (**Localization and language**); functional checklist: **`docs/surfaces/web-ui.md`**.
## Python samples (`examples/`)
See **`examples/README.md`** for layout (**`examples/httpserver/`**, **`examples/acp/`**, **`examples/shared/`**). Scripts may use a project-local interpreter (`.venv` recommended); follow each script header for prerequisites.
- **`examples/build_coddy.sh`** - runs **`make build TAGS="http scheduler memory"`** (override **`TAGS`** as needed) and prints **`coddy -v`**.
- **`examples/test_acp.sh`** - wrapper that runs **`examples/acp/test_acp.sh`** (all ACP **`acp_*.py`** demos in one pass).
- **`examples/test_httpserver.sh`** - wrapper that runs **`examples/httpserver/test_httpserver.sh`** (temp **`coddy serve`**, all HTTP harnesses including **`http_e2e_scheduler_api`** and **`http_e2e_scheduler_agent`**).
Example HTTP scripts that call completion endpoints expect a reachable provider and return non-zero on HTTP errors.