Skip to content

Latest commit

 

History

History
306 lines (238 loc) · 45.5 KB

architecture.md

File metadata and controls

306 lines (238 loc) · 45.5 KB

Architecture: Coddy Agent

Overview

Coddy is a distroless-friendly ACP harness written in Go. At its core it is protocol plumbing (STDIO JSON-RPC server, sessions, configuration, MCP wiring) plus a ReAct execution loop backed by pluggable LLM providers. Ship it as one binary suitable for scratch or distroless images, sidecars, CI sandboxes, or local installs.

The default toolset and prompts are tuned so the harness presents as an interactive coding agent (ACP clients spawn coddy acp; users get filesystem, commands, MCP, project rules from .coddy/.cursor/.claude/.codex rule trees plus nested AGENTS.md files under session cwd, and skills from skills.dirs). That coding-agent surface is a productized profile on top of the harness, not the only way to run Coddy.

High-Level Architecture

┌──────────────────────────┐   ┌──────────────────────────────────┐
│   ACP client (editor)    │   │  Messenger (Telegram, …)         │
│      Zed / scripts       │   │  (build tag: gateway.telegram    │
└────────────┬─────────────┘   │             or gateway)          │
             │ JSON-RPC 2.0    └─────────────┬────────────────────┘
             │ over stdio                    │ long-polling
             ▼                               ▼
┌────────────────────────┐    ┌────────────────────────────────────┐
│   ACP Server Layer     │    │  Gateway Hub (external/gateway/)   │
│  initialize            │    │  one goroutine per adapter         │
│  session/new           │    │  auto-restart on error             │
│  session/prompt        │    └──────────────┬─────────────────────┘
│  session/cancel        │                   │
└────────────┬───────────┘                   │
             │                               │
             └──────────────┬────────────────┘
                            │
                            ▼
            ┌───────────────────────────────┐
            │        Session Manager        │
            │  per-session state, mode,     │
            │  history, rules, skills       │
            └───────────────┬───────────────┘
                            │
                            ▼
            ┌───────────────────────────────┐
            │      ReAct Agent Loop         │
            │  [THINK] → [ACT] → [OBSERVE]  │
            │  → loop or [ANSWER]           │
            └──────┬──────────┬─────────────┘
                   │          │
        ┌──────────┘    ┌─────┴──────┐   ┌─────────────┐
        ▼               ▼            ▼   ▼             │
   ┌─────────┐    ┌──────────┐ ┌──────────────┐        │
   │   LLM   │    │  Tools   │ │  MCP Clients │        │
   │Provider │    │Registry  │ │  (external)  │        │
   └─────────┘    └──────────┘ └──────────────┘        │
                                               ┌───────┘
                                               │
                                    ┌──────────▼────────────┐
                                    │  optional external/   │
                                    │  memory  scheduler    │
                                    └───────────────────────┘

Component Descriptions

ACP Server Layer (internal/acp)

Implements the JSON-RPC 2.0 server that speaks the ACP protocol over stdio. Handles:

  • initialize - version negotiation, capability exchange
  • session/new - create session, connect MCP servers, return modes and Session Config Options (model + mode selectors)
  • session/load - restore a persisted bundle from disk ($CODDY_HOME/sessions by default, usually ~/.coddy/sessions), replay history via session/update
  • session/list - enumerate persisted sessions (ACP sessionCapabilities.list)
  • session/prompt - receive user message, start ReAct loop
  • session/cancel - cancel in-progress turn
  • session/set_mode - switch between agent, plan, and ask modes (legacy, kept in sync with config options)
  • session/set_config_option - change mode or model for the session (preferred ACP API)

Session Manager (internal/session)

Maintains the state for each conversation session:

  • Conversation history (messages, tool results)
  • Current operating mode (agent / plan / ask)
  • Optional model override per session (when the user selects a model via ACP)
  • Connected MCP server clients
  • Working directory
  • Active context (skills + project rules in separate prompt sections)
  • In-memory plan entries for todo tools (session.Plan), mirrored to todos/active.md when persistence is enabled (filesystem.go)
  • Child sessions for subagent runs (subagent.go): CreateSubagentSession, RunSubagentTurn and RetireSubagentSession implement agent.SubagentRuntime, so a child is created, run for its one turn and retired through the manager, never built inside the agent. A child carries an ordinary sess_ id and its bundle is written inside its parent's, under session.ChildSessionsDirName (subagents/), by FileStore.EnsureChildLayout; FileStore.SessionPath resolves a nested id from an index the store fills as bundles are created and, for a bundle written by an earlier process, by walking the root. Every prompt path against a child answers ErrSubagentReadOnly (409 over HTTP), decided on the bundle's subagentRun metadata rather than on the shape of its id; ListSnapshotsWith(ListOptions{IncludeSubagents: true}) is the only listing that descends into the nested bundles; SessionTree / DeleteSessionTree walk that directory tree and remove a parent together with its descendants, stopping their tasks first. See docs/features/subagents.md.

ReAct Agent Loop (internal/agent)

The core reasoning engine (react.go):

  1. Loads tool definitions from internal/tooling.Registry.AllToolDefinitions, applies the session ToolSet from internal/agent/toolsets.go (empty set means no filter), then appends MCP tool definitions from connected servers when the mode is agent or plan (never in ask mode). In ask and plan mode a call that names a filtered-out tool (for example one replayed from history) is also refused at execution time; plan lets every MCP tool name (server__tool) through to callMCPTool, which applies the connected-server check and the per-tool disable switches.
  2. Builds the system prompt from internal/prompts.Render: embedded defaults or files under prompts.dir named by prompts.agent_prompt, prompts.plan_prompt, and prompts.ask_prompt (defaults agent.md, plan.md, and ask.md). Template data includes CWD, tools markdown, skills markdown, rules markdown ({{.Rules}} via internal/rules), optional TodoList and Memory, ModelSwitch (whether the turn is offered switch_model; the built-in templates describe the tool only then, so a subagent or a configuration with nothing to switch is not told about it), BackgroundWake (whether a finished background task can wake the session; where it cannot - a subagent, a scheduled run, coddy -p - the templates tell the model to collect results before the turn ends instead of promising a wake), plus UTCNow (RFC3339 UTC refreshed on every render). Coddy then appends an <environment_context> block containing <os>, <arch>, and the detected <shell>, even when a custom prompt template is used. The message is rendered once per turn and then frozen, so the provider's prompt cache keeps the conversation behind it; the built-in templates render neither UTCNow nor TodoList, and what moves during a turn is sent after the history in a <turn_context> block (internal/agent/turn_context.go, and The turn context block in docs/contributing/react-agent.md).
  3. Prepends that system message to the session message list and appends the newest user turn.
  4. Before every LLM invocation inside one session/prompt, appends the <turn_context> block after the history (internal/agent/turn_context.go): the wall clock, the todo checklist after a coddy_todo_* call, the rules a filesystem tool call activated since the prompt was frozen, the memory subagent's report, and the background tasks of the session that are still running. The system message itself is not touched - rewriting it would throw away the provider's cached copy of the conversation behind it. The exception is a template under prompts.dir that prints {{.UTCNow}} or {{.TodoList}} itself: it is re-rendered per call as before, and gets no block.
  5. Streams the LLM response, executes tool calls (each one passing through the operator hooks of internal/hooks: PreToolUse before the permission gate, PostToolUse or PostToolUseFailure after the tool), appends assistant and tool messages.
  6. Loops until there are no tool calls, the max_turns cap is reached (165 by default; an explicit 0 disables it), the loop guard stops a runaway turn, or cancellation. A turn stopped by max_turns or max_tokens leaves a notice that says so in the session's UI log and in SessionPromptResult.StopNotice. A step that stops at max_tokens is first asked again in words (6b), whether it wrote nothing or stopped in the middle of its text or of a tool call. The turn ends this way only when the retries are spent, the user stopped it or no step is left under max_turns. 6a. Loop guard (agent.loop_guard, on by default, internal/agent/loopguard.go): a streamed channel that degenerates into repeating the same passage has its stream cancelled and the repeated run stripped from the stored message, and a tool call repeated with identical canonical arguments stops being executed. The model is nudged to change course up to agent.loop_nudge_max times, after which the turn ends with StopReasonRefused and a UI notice. 6b. Recovery and transport retries share agent.llm_retry_max extra attempts per model step (default 3, explicit 0 disables them). The initial request is not charged; tool progress or a new follow-up starts a fresh allowance. Within that allowance, a first-token timeout with no output can be replayed once, and a reply with neither text nor tools can be replayed once then nudged up to twice. Each recovery uses a normal max_turns iteration; transport retries stay inside an iteration. A step stopped by max_tokens is not replayed, because the same request would hit the same cap: it is answered with a short message at the end of the history, up to twice in a row. The message depends on the step. If the step produced no text and no tool call, the model is asked to keep its reasoning short and to split large writes. If it was cut off in the middle of visible text, that text stays and the model is asked to continue exactly where it stopped. If it was cut off inside its last tool call, that call is dropped (never run, never stored) and the model is asked to make it again as several smaller calls; the calls written whole before it run. Replays and nudges leave the empty assistant message out of the LLM-facing history while retaining its signed reasoning in the transcript. Every history built from the transcript (the next turn included) leaves out an assistant message that has no text, no tool call and no signed reasoning, since it has nothing to replay and would put two assistant turns in a row. The same history joins the assistant messages that still follow each other (a text the output limit cut off and the step that went on from it, the text before a cut-off tool call and the step after it) into one, unless one carries signed reasoning or the first has a tool call. Loop guards, Stop hooks, fallback models and opt-in quota-reset waits keep their own limits, so this is not a lifetime cap on all LLM calls in a turn.
  7. On session/cancel (or HTTP POST /coddy/sessions/{id}/cancel) while the LLM stream is active, stream providers return context.Canceled together with any Response body accumulated so far; react.go appends that assistant content to session history when non-empty, then ends the turn with StopReasonCancelled. GET /coddy/sessions/{id}/messages can briefly trail that append until the filesystem bundle is read again.

LLM Provider (internal/llm)

Abstracted interface for LLM backends. Configured via config.yaml. The Anthropic serializer omits an assistant history row when it has no replayable non-whitespace text, tool use, or signed thinking block and a later message follows it. A signature without thinking text cannot be replayed and must not become an empty text block. A trailing unreplayable assistant row, or a tool result immediately after one, is refused before contacting Anthropic: omitting it could turn an earlier assistant answer into a continuation prefix, and the tool result has no replayable tool use to answer. The row remains in the session transcript; this wire projection does not change the agent's recovery or retry allowance.

Every error a provider returns is prefixed with the providers[].name it came from and the address that request actually reached (provider_label.go), because the address is not always the one the user has in mind - type: openai with no api_base talks to https://api.openai.com/v1. The label sits outside the resilient wrapper, so retry classification still reads the untouched upstream error, and it wraps rather than replaces the cause, so errors.Is and errors.As keep working.

The resilient wrapper (resilient.go) repeats a call when isRetryableLLMError says the failure belongs to the attempt, not to the request; each retry draws one slot from the agent.llm_retry_max shared per-step budget (step 6b above): HTTP 429, 408, 500, 502, 503, 504 and 529 (overloaded); an overload the provider reported inside the stream before any delta, or an error there whose numeric code is one of those statuses (both told by inBandStatus from the fields of the decoded error object); a transport failure with no status while nothing reached the caller (an unexpected EOF, a connection reset, an HTTP/2 stream the peer reset - stream error: stream ID N; INTERNAL_ERROR; received from peer, as net/http prints it, with no http2: prefix - a GOAWAY, a connection closed because its liveness ping went unanswered, or a stream the idle guard cut before any delta, or a frame or event that arrived framed but ended inside its JSON, before any delta); and a request that never left the client - a TLS handshake that timed out or a dial that did not complete (isDialFailure: a timeout, no route, a temporary DNS failure). The dial check runs ahead of the context.DeadlineExceeded gate, because a dial timeout matches that sentinel just like the caller's own timer; the caller's timer is ruled out earlier by the ctx.Err() check in callWithRetry. A cancellation, an unknown host and any failure after deltas were emitted stay final. The cut inside an event is not a transport failure but the decoder's: a *json.SyntaxError with its end-of-input diagnostic, which the OpenAI-compatible, Codex and Anthropic readers classify as a truncation through streamDecodeTruncation, never by matching the message text of an arbitrary JSON error. The OpenAI-compatible reader has the frame, so the error must also sit at its end; the SDK streams of Codex and Anthropic hand out no payload and go by the diagnostic alone, a bounded heuristic that takes a malformed event with whitespace inside a token for a cut as well, which is the safe side of the doubt: the text is kept, no tool call runs, and the request is retried before output or continued after it, never replayed. The OpenAI-compatible reader (openai_stream.go) also dispatches a last frame the server closed without a blank line, so a body cut inside a data: line is the same truncation there; a frame that is not JSON at all (undecodable SSE frame: ...) is final and never retried, before or after output, whatever status or transport phrase its text holds: the typed stream errors are matched ahead of the message scan of httpStatusFromError, so server bytes are never read as a status.

The transports every provider's HTTP client is built on live in transport.go, one per proxy setting (an empty providers[].proxy and inherit share the one that asks the environment's HTTPS_PROXY, none has one with no proxy function at all, every proxy URL one of its own) and shared across the providers built for every turn, so a request reuses the previous turn's TLS session instead of opening one of its own. The requests a row makes besides its completions - the model list, the account usage, a sign-in - take the same transport through HTTPClientForProviderProxy, so the route of a row is decided in one place. They differ from http.DefaultTransport in two ways. HTTP/2 liveness: a connection that carried no frame for 30 s is asked for a PING, and one that does not answer within 15 s is closed, so a tunnel or a proxy path that died without a FIN or a RST surfaces as http2: client connection lost and is retried, instead of a request that waits forever - a proxy in the path (HTTPS_PROXY, providers[].proxy) answers TCP keepalives itself, and only an end-to-end frame proves the far side is there. And the stall guard: with agent.llm_stream_idle_timeout_ms set (five minutes by default), a text/event-stream body that sends nothing for that long after its first bytes is cut with an error IsStreamStalled recognises. The Codex backend answers its stream with no Content-Type at all, so a successful answer that names none counts as an event stream when its request asked for one in Accept (isEventStream), and the Codex provider asks for one, as the Codex CLI does. Each provider's stream keeps the text and reasoning already delivered next to that error, exactly as it does for a truncation, and the ReAct loop persists them before ending the turn with the stall named; the wrapper retries the request only while nothing reached the caller. The wait for the first byte is the first-token guard's, and a blocking (stream: false) answer, which arrives in one piece, is bounded by providers[].timeout_ms alone.

Supported backends (see docs/getting-started/configuration.md for shapes):

  • OpenAI and OpenAI-compatible HTTP APIs (type: openai)
  • Anthropic (type: anthropic)
  • Ollama and other local OpenAI-compatible stacks (api_base)
  • A local agent that speaks the Agent Client Protocol (type: acp, acp*.go), over the protocol's Go SDK github.com/coder/acp-go-sdk (the JSON-RPC connection and the requests Coddy sends): one process per row by default (per Coddy session with process: per_session, per workspace when a value names ${CWD}) in a package-level pool, one ACP session per Coddy session bound through llm.WithConversation on the stream context, the agent's permission requests forwarded through Conversation.AskPermission; design record docs/plans/acp-provider.md

Tools Registry (internal/tools)

The tool types and registry mechanics live in internal/tooling (Tool, Env, Registry, JSON ParseArgs, AllToolDefinitions). The internal/tools package is the composition root (NewRegistry wires everything) and exposes the same APIs via type aliases so call sites such as internal/agent keep importing tools only.

  • internal/tools/web - http_request (http_request.go: the curl-like tool, ParseHTTPRequest is the one validated value the tool sends and the permission gate in internal/permission/http.go decides on; client.go: the shared client - transport per route (proxy, certificate check), redirect policy, per-hop guard), websearch (several engines asked in parallel behind the searchFn seam, each reporting ok / empty / blocked / error so a backend that was turned away is named rather than counted as nothing found; merge deduplicated by normalised URL, relevance gate against decoy result sets) and webfetch (fetch public http(s) pages, readability + Markdown; SSRF guards)

Built-in implementations are grouped in subfolders under internal/tools/:

  • internal/tools/fs - path helpers (paths.go with ResolvePath, CheckInsideCWD, PathEscapesCWD, ToolPathsEscapeCWD) and tools (read.go read, glob.go glob, grep.go grep, print_tree.go print_tree (directory tree, read-only), write.go write, edit.go edit, patch.go apply_patch, mkdir, rmdir, touch, rm, mv). grep uses a system rg when one is available (the pattern is passed to it untouched) and otherwise falls back to the built-in Go walker/matcher in search.go. glob uses the same built-in walker when rg is unavailable, so filesystem discovery also works in Windows and distroless binaries without sidecar executables.
  • internal/platform - shared host shell detection: pwsh → powershell → cmd on Windows and bash → sh elsewhere; also renders the prompt environment context.
  • internal/tools/shell - run_command, bound to the shared detected shell and documented to the model with platform-appropriate command examples.
  • internal/tools/todo - todo/plan list (coddy_todo_plan_read, coddy_todo_plan_replace, coddy_todo_plan_archive, coddy_todo_item_add, coddy_todo_item_remove, coddy_todo_item_update, coddy_todo_item_move)
  • internal/tools/spawn_agent.go - spawn_agent, delegation of a self-contained task to a subagent (registered when subagents.enable). The tool only forwards to the tooling.Env.SpawnAgent hook that internal/agent wires, so the registry stays below the session layer; the runtime, the project trust check and the child session live in internal/agent/subagent.go, internal/subagents and internal/session. See docs/features/subagents.md.

Tool exposure - internal/agent/toolsets.go defines a ToolSet name allowlist per mode. An empty ToolSet means no filtering (all tools registered in the session registry, plus MCP definitions). Plan mode uses a fixed allowlist on registry builtins (read, glob, grep, print_tree, websearch, webfetch, run_command, question, spawn_agent, and the read-only background, config, plan and skill tools listed in that file), then MCP tools from connected servers are appended the same way as in agent mode. Ask mode uses a smaller read-only allowlist with no shell, no plan, config or MCP tools and no spawn_agent. A session that is itself a subagent run is filtered once more to the tool set the runtime computed for it, in agent and plan mode alike.

Agents see:

  • agent mode - every built-in registered by internal/tools.NewRegistryFor (filesystem, shell, todo, optional scheduler tools, websearch, webfetch, question, plan_exit, spawn_agent, etc.) plus MCP tools from connected servers.
  • plan mode - the allowlisted builtins above plus MCP tools. Built-in writes, todo tools and scheduler tools are not advertised to the LLM. The memory tools are in no ordinary session's registry at all: they are registered into the memory subagent's registry only (internal/agent/memory_hooks.go).

run_command, optional write paths, out-of-tree paths, and interactive question flows still coordinate with the client (session/request_permission for destructive paths; HTTP streaming uses event: question plus POST /coddy/sessions/{id}/question).

Messenger Gateway (external/gateway)

The gateway is a subsystem of coddy serve (gateways.telegram.enable) that lets messenger bots (Telegram today, others via the same interface) drive the same session manager and ReAct loop the HTTP API and coddy acp use. When the HTTP surface is on in the same process, a chat turn is also published into that session's composer relay, so a browser can watch it while it runs.

Compiled only when built with -tags gateway.telegram (Telegram) or -tags gateway (all adapters). Without these tags, gateways.telegram.enable: true is a startup error naming the tag.

Key packages:

Package Role
external/gateway Adapter interface, Hub, Start() entry point
external/gateway/access Access control: CanAccess, EffectiveAccess, EffectiveIsolation
external/gateway/sessionstore Store: maps stable chat/user keys to Coddy session IDs; Reset on /clear
external/gateway/telegram Bot (polling, trigger rules, ACL), Sender (implements acp.UpdateSender)

Data flow for one incoming message:

  1. Adapter receives raw update, normalises it to IncomingMessage.
  2. access.CanAccess rejects the message if the user fails the configured access level.
  3. sessionstore.SessionKey derives a deterministic string key from gateway name, chat ID, user ID, and isolation mode.
  4. store.Get(key) returns the current Coddy session ID for that key (creating one on first use).
  5. manager.EnsureHTTPSession loads or creates the session bundle.
  6. manager.HandleSessionPromptWithSender runs the ReAct loop with the adapter's Sender.
  7. sender.Flush() sends accumulated text back to the chat.

Extending with a new adapter — implement gateway.Adapter, add a Sender that satisfies acp.UpdateSender, tag files with //go:build gateway || gateway.<name>, append to Start(). See docs/surfaces/gateway.md for the full walkthrough.

Optional external tool packages (scheduler, memory)

Some features live under external/ and define tools that are not registered through internal/tools.NewRegistry, but still use the same internal/tooling.Tool shape as the core harness.

Contract (mirror external/scheduler/tools/job_get.go):

  1. One tool per file - a package-local constructor returns *tooling.Tool with Definition (name, description, InputSchema) and Execute in one place. Execute takes context.Context, JSON args as a string, and *tooling.Env (use CWD or other fields when the tool needs session context; pass &tooling.Env{} when unused).
  2. JSON schema maps - prefer map[string]interface{} for InputSchema and []interface{} for required and enum lists so OpenAI and Anthropic marshaling stay consistent with existing scheduler tools.
  3. register.go - collects constructors. external/scheduler/tools exposes RegisterTools for the main agent registry. external/memory/tools exposes PersistTools and RecallTools, which external/memory.Tools hands to internal/agent for the registry of the memory subagent (a child session the runtime starts per user turn), never for an ordinary session.
  4. Naming - scheduler files use the job_*.go prefix; memory tool bodies use the mem_*.go prefix; external/memory/tools keeps env.go, names.go, register.go without the mem_ prefix.

MCP Client (internal/mcp)

Connects to external MCP servers declared in two files (the global ${CODDY_HOME}/mcp.json and the project ./.coddy/mcp.json, which overrides a name) plus servers specified in session/new. config.yaml declares none: an old mcp_servers list is moved into the global file on load (internal/config/legacy_keys.go), and the session manager watches that file (ReloadMCPDeclarations). Transports (dispatched by mcp.Connect over a shared transport interface):

  • stdio - local subprocess, newline-delimited JSON-RPC; the process lifetime is transport-owned (the connect ctx only bounds the handshake)
  • streamable HTTP (type: http) - JSON-RPC POSTs answered as JSON or SSE chunks, Mcp-Session-Id round-trip, automatic legacy-SSE fallback (capability: mcpCapabilities.http)
  • legacy HTTP+SSE (type: sse) - GET event stream announcing the POST endpoint (capability: mcpCapabilities.sse)

mcp.Probe backs the /coddy/mcp management API (connect, tools/list, close); manage.go resolves which file owns a server for enable/disable persistence. Tools from MCP servers are appended to the LLM tool list in agent and plan modes (never in ask mode; see internal/agent/react.go), filtered per turn by the disable switches.

Skills loader (internal/skills)

Loads SKILL.md from the default skill folders and then the extra skills.dirs (see docs/features/skills.md). The defaults, always read, lowest → highest priority: ${HOME}/.agents/skills (shared with npx skills/npx skillsbd), the project's .agents/skills, ${CODDY_HOME}/skills (Coddy's own), the project's .coddy/skills; each entry of skills.dirs comes after them (config.Skills.SearchDirs, a folder named twice read at its last place). The standard delivery - the skills the binary carries in internal/skills/bundled/ - is prepended below all of them, and is also written into ~/.coddy/skills on first sight (internal/skills/seed.go), so the copy on disk is what a session actually reads and the in-binary one is the fallback for a home that could not be written.

Subagents (internal/subagents)

Loads subagent definitions - markdown files with YAML frontmatter whose body is a child agent's role - from four default folders, always read (${HOME}/.agents/agents, ${CWD}/.agents/agents, ${CODDY_HOME}/agents, ${CWD}/.coddy/agents), and then the extra subagents.dirs (config.Subagents.SearchDirs; later dirs override earlier ones by name, a folder named twice is read at its last place, and the three built-ins general, explore and the hidden crossreview sit below all of them), decides each file's scope on canonical paths (project inside the workspace, user elsewhere), holds the trust receipts for project-scope files (TrustStore, <home>/subagents-trust.json, keyed by canonical workspace, name and file digest; policy subagents.project_trust), bounds concurrent runs with a process-wide Limiter, and renders the catalog (the prompt block for the parent model, the table for coddy agents list, the rows for GET /coddy/subagents). It also owns the pure narrowing rules: permission mode never widens, the child's tool set is an intersection with the parent's, timeouts resolve like the pool's. The package knows nothing about sessions or the loop; internal/agent/subagent.go applies its decisions, runs the child through the session manager and registers the run in internal/bgtask with Pool.Launch. Guide: docs/features/subagents.md.

Hooks (internal/hooks)

Loads operator lifecycle hooks - commands that read one JSON document on stdin and answer with an exit code plus optional JSON on stdout - from the files of hooks.files (defaults ${CODDY_HOME}/hooks.json, the workspace's .claude/settings.json and .claude/settings.local.json for compatibility, ${CWD}/.coddy/hooks.json), in Claude Code's file shape (definition.go: event -> matcher groups -> command handlers with timeout, async, failClosed, args for the exec form, commandWindows). matcher.go applies Claude Code's matcher rules (exact list or unanchored regex) with tool-name aliases, so Bash matches run_command and mcp__server__tool matches an MCP tool. loader.go decides each file's scope on canonical paths (project at or under the workspace, user elsewhere) and holds project files under hooks.project_trust the way MCP declarations and subagent definitions are held: trust.go keeps the receipts (<home>/hooks-trust.json, keyed by canonical workspace, workspace-relative file and digest; coddy hooks list|trust|untrust, GET /coddy/hooks, POST /coddy/hooks/trust|untrust), catalog.go renders the rows those surfaces show, and a held file is reported once per session as a notice-level UI log row. runner.go builds the payload (session fields plus event fields), runs the matching handlers one after another with the session cwd, the CODDY_* / CLAUDE_PROJECT_DIR environment, a timeout and a detached process group through internal/platform, and merges their answers into one Outcome (the most restrictive decision wins; updatedInput chains; every additionalContext is kept; a crash or timeout is a non-blocking error unless the handler fails closed). The package knows nothing about sessions or the loop; internal/agent/hooks.go builds the runner per turn and executeToolCall consults it: PreToolUse before the permission gate (deny, allow past the prompt, force the prompt, rewrite the arguments, add context), PostToolUse / PostToolUseFailure after the tool. Run fires UserPromptSubmit before the prompt becomes a message (a block rejects the prompt; context goes into the ## Hook context block of the system prompt), the loop fires Stop before end_turn (a block submits the reason as the next user message, at most hooks.stop_loop_limit times per turn), CompactSession fires PreCompact (veto) and PostCompact, and session.Manager fires SessionStart on session/new and session/load, storing the returned context on the session (hookContext in session.json). subagent.go fires SubagentStart (a block refuses the spawn, context is prepended to the child's task) before a child starts and SubagentStop after its turn, and the permission gate fires Notification (permission_prompt) before asking the client. Guide: docs/features/hooks.md; design record: docs/plans/hooks.md.

Rules engine (internal/rules)

Discovers .mdc / .md rules from one project folder under session CWD - the first of .coddy/rules, the tool-neutral .agents/rules (system id 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, and the same chain under every subfolder on the way down to a file a tool or a mention reaches (Factory.Nested: read on demand, each rule anchored at its own subfolder and applied there only) - plus the operator's own ${CODDY_HOME}/rules (system id user), which applies in every workspace and loses a file name clash to the project. The AGENTS.md and DESIGN.md documents (agents.md convention) come in three layers that no setting turns off (project_docs.go, agents.go): the agent home's pair, then the workspace's, both in the system prompt, and the nested pair of every folder on the way down to a file a filesystem tool or a mention reaches, attached to that tool result or user message. The rules that always apply go into {{.Rules}} separately from skills, after the first two layers, and the files of instructions.files into {{.Instructions}} after them; every file once, by its real path (DocKey). The standing parts are rendered once per rules generation of the session (a compaction, a config reload or a workspace switch starts the next one), so they do not move the cached system message, and internal/agent/system_prompt.go places them in whatever template the turn runs on, appending the documents a template did not print. See docs/features/rules.md.

The extension selects the dialect (markdown.go): .mdc is read as a Cursor rule (description, globs as a comma-separated string or list, alwaysApply, default manual), .md as a Claude Code rule (paths; unconditional without them). Headers are read as YAML first, then by a lenient line reader, because Cursor's own globs: **/*.go is not valid YAML. Globs use doublestar syntax anchored at the session cwd (Rule.Root, MatchGlob).

Activation uses globs, alwaysApply, @mention and filesystem tool paths (MatchScoped: a read/edit/... targeting a matching file, or a path in a nested AGENTS.md subtree or under a subfolder whose rule folder holds the rule and matches its globs, activates the rule); see docs/features/rules.md. A rule a tool call activates rides in that call's result (llm.Message.Rules, internal/agent/rules_activation.go), and a rule a mention brings - one the user names, one a mentioned path activates - in that user message (internal/agent/mentions.go); neither enters {{.Rules}}, and each comes once until a compaction folds it away.

Mentions (internal/mention)

The one grammar of @ references (grammar.go: paths relative, absolute, under ~ or above the workspace, quoted paths, line ranges :N-M and #L, folders, @session: / @rule: / @agent: / @coddy:, web pages; every reading of an ambiguous token, longest first, with the one that exists on disk winning), path resolution against a session's workspace (paths.go), the workspace index for completion (index.go: git ls-files in a checkout, a walk elsewhere, cached per root and rebuilt when a mention starts), fuzzy ranking (fuzzy.go) and the <coddy_attachment> element a resolved mention is written as, with its display twin ForDisplay (attachment.go). Pure: no sessions. internal/session/mentions.go resolves a prompt's mentions once, when the message is sent - a file, a folder listing, another session's digest, a rule, a subagent, a plan, a page of the built-in documentation, a web page through the fetcher internal/tools/web registers - and mention_search.go answers completion for the console, the web UI and a remote console (GET /coddy/mentions); mention_check.go runs the same resolver dry, reading nothing, so the web composer highlights only a mention sending would attach (POST /coddy/mentions/check). internal/agent/mentions.go renders the attachments into the user message and adds the rules the mentioned paths activate to that same message, so no mention moves the system prompt. The web composer's twin of the grammar is external/ui/src/ui/skills/draftAt.ts; both are tested against internal/mention/testdata/grammar_cases.json. See docs/features/mentions.md.

Built-in documentation (internal/docs)

The pages of docs/nav.yaml are embedded by docs/embed.go (package docs at the root of the tree, the only place the embed pattern can reach them from), in English and in the Russian translation under docs/ru/, and read by internal/docs: LoadLang / For (Default for English) build the Library of groups and pages of one language, each page split into headings with the anchors GitHub gives them (Anchor, which internal/docsgen's link check uses too, so an anchor the check accepts is one every reader finds), its links rewritten for a reader outside the repository (coddy:<slug>#<anchor> between pages, GitHub at the release tag for images and repository files). Search ranks sections with BM25F over the page title, the section heading and the text, the index built once per language on first use; Resolve turns every spelling of a page into the page and its anchor; Page.Read returns a page or a section under a budget of characters with the offset to continue at. Pure: it reads only the embedded tree. The consumers are the tools coddy_docs_search / coddy_docs_read (internal/tools), the @coddy: mention (internal/session/mentions.go, mention_search.go), GET /coddy/docs* (external/httpserver/docs_http.go), the console's help (external/cli/docs_modal.go) and coddy docs (cmd/coddy/docs.go). See docs/features/built-in-docs.md.

Config (internal/config)

YAML-based configuration. Resolution uses CODDY_HOME (default ~/.coddy), CODDY_CWD, CODDY_CONFIG, optional config.yaml in the process working directory when $CODDY_HOME/config.yaml is absent, and CLI flags (see docs/getting-started/configuration.md and README.md).

Session Modes

agent mode (default)

  • Full tool access (read, write, run commands)
  • Executes tasks end-to-end
  • Requests permission before destructive operations
  • Suitable for: code generation, refactoring, debugging

plan mode

  • Narrow registry tool surface enforced by internal/agent.ToolSetForMode("plan")
  • read, glob, grep, print_tree, websearch, webfetch, run_command, question, spawn_agent (a child of a plan-mode parent stays in plan mode), the plan_* document tools and the rest of the allowlist (Operating modes has the whole list), plus any MCP tools from configured servers
  • No built-in workspace writes or coddy todo tools in the advertised set (switch to agent for those)
  • Like ask, the allowlist is also enforced at execution time (toolCallRefusedByMode, on the permission-resume path as well): a tool call outside the set, for example one replayed from history, is refused with a notice instead of being executed, and approving a pending call with allow always records no grant. MCP tool names pass the check and are left to callMCPTool
  • Suitable for: design docs, specs, architecture planning, external research, and light shell or MCP inspection without offering full mutating builtins

ask mode

  • Read-only research surface enforced by internal/agent.ToolSetForMode("ask")
  • read, keep_result, glob, grep, print_tree, websearch, webfetch, question, load_skill, coddy_docs_search, coddy_docs_read — no shell, no plan or todo tools, no config tools, and no MCP tools
  • The allowlist is also enforced at execution time, MCP tool names included: a tool call outside the set (for example replayed from history) is refused with a read-only notice instead of being executed; approving such a pending call with allow always records no grant either
  • A @plans/<slug>.plan.md mention or runPlanSlug metadata never starts a plan run in ask mode (the mention is inlined as reading material, the metadata shortcut is refused), and the memory subagent runs recall-only (no memory writes)
  • Suitable for: questions about the codebase, code review and diagnosis, and web research without any mutation surface

Mode switching:

  • Client calls session/set_config_option with configId mode (preferred) or session/set_mode with agent, plan, or ask
  • Agent sends current_mode_update and config_option_update when mode changes

Directory Structure

Top level after git clone (folder name is arbitrary; coddy-agent is common):

.
├── cmd/coddy/                   # CLI entry (acp, http, sessions, skills)
├── internal/                    # core harness (acp, session, agent, config, tools, …)
├── external/
│   ├── memory/                  # long-term memory subagent (`-tags memory`)
│   ├── httpserver/              # optional REST gateway (build tag http)
│   ├── ui/                      # Vite SPA sources (embedded when built with http+ui)
│   ├── scheduler/               # optional cron runner (build tag scheduler)
│   └── gateway/                 # messenger gateway (build tag gateway | gateway.telegram)
│       ├── access/              # ACL: CanAccess, EffectiveIsolation
│       ├── sessionstore/        # chat/user → session ID mapping
│       └── telegram/            # Telegram bot adapter (tgbotapi v5)
├── examples/                    # ACP and HTTP Python harnesses
├── docs/                        # guides (see docs/README.md)
├── Dockerfile
├── docker-compose.yml
├── docker-compose.dev.yml
├── config.example.yaml
├── go.mod
├── go.sum
└── README.md

Optional layers external/httpserver, external/ui, external/scheduler, and external/memory are omitted from the binary unless you pass the matching Go build tags; see docs/contributing/build.md and README.md. Long-term memory runtime behavior is toggled with memory.enable when the binary was built with memory.