# codey Codey is a coding agent harness in Go. Point it at a repository, tell it to make the failing test pass, and measure how often it actually does. The code is the byproduct. The deliverable is [METRICS.md](METRICS.md). [PLAN.md](PLAN.md) is the build plan this follows. ## Quick start ```bash go build -o codey ./cmd/codey ./codey -C /path/to/a/throwaway/clone # full-screen TUI ./codey -C /path/to/repo -p "why does TestFoo fail?" # one prompt ``` If you are logged into Claude Code, that is all the setup there is: Codey borrows the OAuth credential from `~/.claude/.credentials.json` when nothing else supplied one. Otherwise, or to use a different account: ```bash export ANTHROPIC_API_KEY=sk-ant-... ``` A different endpoint — a gateway, a proxy, a local server speaking the Messages API: ```bash ./codey -base-url http://127.0.0.1:4000 -api-key ... -C . # or ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN ``` Flags beat the saved config, which beats the built-in defaults; the config is what [the setup wizard](#setup) writes on a first run. `-auth-token` sends `Authorization: Bearer`, which is what OAuth tokens and most gateways want; `-api-key` sends `x-api-key`. A non-default endpoint announces itself on stderr, because your prompt and every file the agent reads go wherever that URL points. The Claude Code login is the last resort, tried only when no flag and no environment variable supplied a credential, and only against Anthropic's own endpoint — a credential found on disk is not one you chose to send, so it is never forwarded to a `-base-url` you pointed Codey at. `-no-claude-code-auth` skips it entirely. Note that the credential is issued to Claude Code; using it from another client is outside Anthropic's terms for it, which API keys and gateway tokens do not carry. On macOS Claude Code keeps this in the Keychain rather than a file, so there is nothing to borrow there. An expired access token is refreshed automatically; the credentials file is left alone unless the server rotates the refresh token, in which case the replacement is written back — leaving the dead one there is what would break your Claude Code login. Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`, `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`, `-no-tui`, `-no-skills`, `-no-agents`, `-no-claude-code-auth`. ### Flags and thresholds `-compact-at` (default 0.7) is where the conversation is normally folded into a summary, keeping the last six messages verbatim. Early and rare is deliberate: compaction is itself a model call with the history in it, and it is the single biggest thing that breaks prompt caching. `-compact-floor` (default 0.97) is the fraction past which compaction stops being optional. The normal threshold declines a history it cannot split safely — a handful of enormous tool results is too few messages to cut six off the end of — and below the floor that restraint is right. Past it, a verbatim tail is worth nothing if the next request has no room to be sent, so the floor folds anyway and settles for a one-message tail. `/compact` follows the same rule past the floor, since with auto-compaction off it is the only move left. When even that finds nothing to fold, Codey says so, rather than letting the next request fail as a bare API error. `-context-window` (default 1,000,000) is what both fractions are measured against; lower it for a model with less room. `-no-auto-compact` turns the automatic fold off for one run, the same as `"auto_compact": "off"` in the config. The floor then stops the turn instead of folding: the session stays intact, nothing is summarized behind your back, and `/compact` is what makes room. In `-p` mode the stop is a non-zero exit. `-no-agents` leaves out the subagent tools, so the agent works alone. ## The TUI On a terminal, the interactive mode is a full-screen [Bubble Tea](https://github.com/charmbracelet/bubbletea) app. It opens on a welcome block — Codey, then the model, the endpoint and the root: ``` ▄██████▄ codey v0.6.0 █ ██ █ Opus 5 · anthropic ███▀▀███ ~/Work/GO/coding-harness ▀█▀▀█▀ ``` The gopher is drawn in half-blocks, so it needs no patched font, and it is dropped rather than sliced on a terminal too narrow to hold it beside a readable amount of text: half a mascot reads as a rendering bug, where the three lines alone read as a choice. From there it settles into a transcript above a prompt: ``` ⏺ Let me see what the test expects before changing anything. ⏺ ReadFile(path=internal/agent/loop_test.go) ⎿ 1: package agent … ⏺ EditFile(path=internal/agent/compact.go) 4 additions, 1 removal @@ -50,7 +50,9 @@ + if window == 0 { ✢ Noodling… (14s · turn 3 · ↓ 1.3k tokens · esc to interrupt) ● high · /effort ╭──────────────────────────────────────────────────────────────────────────────╮ │ › _ │ ╰──────────────────────────────────────────────────────────────────────────────╯ joni ✳ Opus 5 ◐ 62.2k (6%) ↑ 4.2k ⟳ 58.0k ↓ 1.3k $0.0835 ⏵ approvals on (shift+tab to cycle) · /help for commands ``` Every event hangs off a dot in the left gutter, and everything belonging to it is indented under that dot, so a screen of tool output still reads as a sequence of things that happened. Diffs are shown in full and in colour with a count of what changed; other output is elided after six lines. What the model says is Markdown, and it is rendered as Markdown — headings, weight, lists, block quotes, rules, and tables laid out in columns: ``` ⏺ Findings The refresh path drops the rotated token. Three things are going on: 1. refresh() returns the new pair but never writes it back 2. the old refresh token is already dead by then File │ Line │ Change ────────────────────────┼───────────────────────┼─────────────────────── refresh.go │ 84 │ write back func refresh(tok string) (Creds, error) { // TODO: persist the rotation } ``` Fenced code is syntax-highlighted by [chroma](https://github.com/alecthomas/chroma), and every colour — prose, headings, keywords, strings — comes from the theme in force, so `/setup` repaints Markdown along with everything else. Headings drop their hashes: the levels are told apart by colour and weight instead. It renders as it streams, so a table fills in row by row. Only what the model says goes through this. Tool results and the output of your own `!` and `$` commands are program output and are shown as they came, and `-no-tui` prints raw text so `-p` stays pipeable. The status bar reports the context gauge — the last turn's input size against the window, which drops when the conversation is compacted — and separates fresh input tokens from cache reads, because the gap between the two is what [METRICS.md](METRICS.md) is about. The prompt box grows with what you type, up to six lines. The conversation folds itself into a summary at 70% of the window, and `/compact` does it on demand — before a long task, say, or when the gauge is climbing faster than the work is. `/compact` waits for the current turn to finish, because it rewrites the history that turn is building on. Afterwards the gauge goes blank rather than keep reporting a number the compaction just made untrue; the next turn measures the window again and it comes back. At 97% compaction stops being optional, and it can be turned off altogether: see [the flags](#flags-and-thresholds) below. | Key | Does | | --- | --- | | `enter` | send | | `/` | open the command menu — see below | | `!` | run a shell command yourself — see below | | `$` | the same, through `python3` | | `ctrl+j` | newline without sending | | `shift+tab` | cycle the approval mode | | `ctrl+b` | open the agents sidebar — see below | | `ctrl+r` | expand elided tool output | | `pgup`/`pgdn` | scroll the transcript | | `esc` | interrupt the turn | | `ctrl+c` | interrupt the turn; again when idle to exit | Commands: `/allowed`, `/clear`, `/compact`, `/effort`, `/exit`, `/help`, `/model`, `/mouse`, `/settings`, `/setup`, `/skills`, and one per discovered skill. ### Questions The agent can stop in the middle of a task and ask you something. It calls `ask_user`, the turn parks, and a box comes up with the options it thought of: ``` ● the agent has a question ╭──────────────────────────────────────────────────────────────────────────────╮ │ Storage · question 1 of 2 │ │ Sessions have to live somewhere once the process exits. Where should they go?│ │ │ │ ❯ 1. Postgres (recommended) │ │ what the rest of the stack already runs; no new service to operate │ │ 2. SQLite │ │ a file on disk, and one fewer thing to have running │ │ 3. Keep them in memory │ │ simplest, and they are gone when you quit │ │ 4. something else — type it yourself │ │ │ │ ↑↓ move · enter choose · esc skip the question │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` | Key | Does | | --- | --- | | `↑`/`↓` | move the cursor — the wheel does the same | | `1`–`9` | answer with that option outright | | `space` | tick a box, where the question allows several answers | | `enter` | take the row under the cursor, or confirm what is ticked | | `←` | back to the previous question, to change an answer | | `esc` | skip: the agent is told to decide for itself and say what it chose | The last row is always there and always free text, so you are never cornered by three options that are all wrong; typing an answer is the same key as picking one. A click does what enter would — on the description under a label as well as on the label, because that is what it looks like you are clicking. This is the approval box's twin, and it works the same way underneath: the agent blocks on a channel, the UI keeps running, and an interrupt releases it. Where the two collide the approval goes first — a tool call cannot start until someone says so, and the question is still there behind it. A question whose turn is interrupted goes with the turn, since the tool call waiting for the answer no longer exists. Only the session's own agent gets the tool. A subagent's report goes to its parent, and the parent is not a person to ask. Outside the TUI the same questions are asked a line at a time, and a run with nothing on stdin — a `-p` one-shot in a script — gets no answer, which the tool reports as a dismissal so the model decides for itself and says so. ### The mouse | Does | How | | --- | --- | | scroll the transcript | the wheel, anywhere | | select | drag; double-click a word, triple-click a line, shift+click to extend | | copy | let go — the selection lands on the clipboard on release | | choose | click a menu row, a model, the effort slider, an approval, the mode line | | work with agents | click the agents pill or a row; click the prompt to type again | The prompt box is keyboard-driven: a click does not move the caret in it, and what you have typed is the one thing the mouse cannot select. Dragging against the top or bottom edge of the transcript scrolls, so a selection can be longer than the screen; it keeps going while the pointer moves, because a terminal reporting button events says nothing about a hand held still. Codey runs in the alternate screen, where the terminal has no scrollback of its own to roll — so asking for mouse reporting is what makes the wheel work at all. The cost is the terminal's own text selection, which stops being the terminal's the moment drags are reported to the program. That is why the selection above exists: it is the replacement, and copying goes out over both OSC 52 (which works over ssh) and the desktop clipboard, because terminals disagree about which of the two they allow. `/mouse` hands the mouse back. Selection and copy become the terminal's again, exactly as before this existed, and the wheel goes quiet. `shift`+drag usually reaches the terminal's own selection without turning reporting off at all. A slash at the start of an empty prompt opens the command menu, which filters as you type — a name that starts with what you typed before one that merely contains it: ``` ╭──────────────────────────────────────────────────────────────────────────────╮ │ › /s │ ╰──────────────────────────────────────────────────────────────────────────────╯ ❯ /skills the instruction sets found at startup /systematic-debugging find the cause before changing code (project) /brainstorming explore an idea before building it (personal) ``` `↑`/`↓` move, `tab` or `enter` completes the highlighted name into the prompt — completing is not sending, so there is room to type arguments for a skill or a level for `/effort` — and `esc` dismisses the menu without touching what you typed. The menu offers exactly the commands the TUI answers, so nothing it suggests can come back as "unknown command". ### Running something yourself `!` on an empty prompt points the box at a shell instead of at the model; `$` points it at `python3`. The marker replaces the `›`, the frame changes colour, and `enter` runs the line where the agent's own `bash` tool would have run it — from the working root, with the same timeout and the same output cap. ``` ╭──────────────────────────────────────────────────────────────────────────────╮ │ ! go test ./internal/agent/ │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` ``` ⏺ ! go test ./internal/agent/ ⎿ exit code: 1 (2.3s) --- stdout --- --- FAIL: TestCompactKeepsTheLastExchange (0.00s) compact_test.go:88: history has 2 messages, want 4 … +9 more lines (ctrl+r) ``` **The agent is shown what you saw.** The command and its output are appended to the conversation as a user message, so the next thing you type can be "fix that" and the model already has the failure. Nothing is sent at that moment — it waits in the history and goes up with your next real turn, so several commands can be run before saying anything about them. What reaches the model is the capped report, not the raw firehose: a noisy command cannot cost more context from the prompt box than it would through the tool. The transcript collapses it further to six lines, and `ctrl+r` expands to exactly what the model was given, so the two never drift. `esc` leaves the mode, as does `backspace` over the marker. The mode is per-command — after a run the prompt returns to normal, the way a shell prompt comes back. One command runs at a time; while it does, `esc` interrupts it and a message you type waits rather than jumping ahead of the output it is about. A `!` typed mid-sentence is an exclamation mark. These bypass the approval policy by design: you typed the command, so there is nobody left to ask. They are also the one part of the TUI that runs something without the model's involvement — worth remembering when the root is not a throwaway clone. `/model` asks the endpoint what it serves — `GET /v1/models`, paginated — and opens a picker on the answer: ``` Select model from anthropic · ↑↓ move · enter apply · esc cancel 1. Claude Fable 5.1 claude-fable-5-1 · 1.0M in · $10 / $50 per Mtok 2. Claude Opus 5 claude-opus-5 · 1.0M in · $5 / $25 per Mtok ✓ ❯ 3. Claude Sonnet 5 claude-sonnet-5 · 1.0M in · $2 / $10 per Mtok 4. Claude Haiku 4.5 claude-haiku-4-5-20251001 · 200.0k in · $1 / $5 per Mtok ``` The cursor starts on the model in force, which is also the one marked. The id, the context window and the list price come from the endpoint and from the pricing table; a model neither knows about simply says less. The switch applies from the next turn — the conversation so far is replayed to the new model — and costs already incurred stay priced at the model that incurred them, because a session that switched models was not all billed at the model it ended on. Not every endpoint serves that route. When the fetch fails the picker says so, names the host and the reason, and closes; `/model ` and the `-model` flag still switch without asking anyone's permission. `/effort` opens a slider in place of the prompt, on the level in force — an unset level is the API's own default, `high`, so the marker starts there rather than at an end: ``` Faster Smarter ──────────────────────────────────────▲─────────────────────────────────── low medium high xhigh max ←→ move · 1-5 jump · enter apply · esc cancel ``` Moving the marker changes nothing until `enter`; `esc` leaves the level alone. `/effort xhigh` still applies a level directly for anyone who can type it, and either way it is the `-effort` flag reached from the keyboard. The level in force shows above the prompt on the right. A terminal too narrow for five labels gets `◀ high ▶` and the same keys rather than a track with the words sliced in half. `/model` and `/effort` change the session. `/settings` and `/setup` change what the next session starts with — see [Setup](#setup). `shift+tab` cycles between asking before every mutating call and accepting them all, which is `-yolo` reached from the keyboard. The mode line says which is in force at all times, and the switch is recorded in the transcript, because a mode that silently lets the agent write to disk is not one to keep in a corner. Approvals appear inline as a bordered prompt, answered by number or by the key in brackets — `y`, `n`, `a` for this exact command, `t` for the whole tool. The agent goroutine blocks until you answer, which is the point: the tool has not run yet. Interrupting while that prompt is open denies the call and cancels the turn rather than leaving the agent waiting on a channel nobody will read. The TUI is skipped automatically when stdin or stdout is not a terminal, and by `-p` and `-no-tui`, which use the line-based renderer instead. Both front ends consume the same `agent.Event` stream, so neither can show something the other cannot. ## Subagents The agent can start other agents. `spawn_agent(preset, task)` returns immediately with a short id — `a1`, `a2`, … — and that agent runs in the background on its own goroutine while the main agent carries on with whatever it was doing. `agent_wait(id)` blocks until that one finishes, for the times the parent genuinely cannot proceed without the answer. A subagent is a whole agent, not a subroutine: its own conversation, its own context window, its own model, its own turn loop. The parent never sees its turns — only the report it ends with. That is the point. Work that would have filled the parent's window with file listings fills the child's instead, and comes back a paragraph long. Three presets, compiled in rather than configurable, because a preset the model can invent is a blast radius the model can invent: | Preset | For | Turn cap | | --- | --- | --- | | `explore` | finding things, and reporting paths and line numbers | 20 | | `plan` | working out how a change should be made, without making it | 30 | | `general` | a self-contained piece of work, edits and tests included | 40 | All three get the full tool set — read, glob, grep, edit, write, bash, skill — and none of them gets `spawn_agent`, so subagents cannot spawn subagents. **A preset pins no model and no effort level.** Both are arguments to `spawn_agent`, because what to spend is a judgement about the job and the agent holding the task is the one making it: `model` picks what runs it — a small fast model for mechanical search, a strong one for work that has to be right — and `effort` picks how hard it thinks, `low` through `max`. Omit either and it inherits the session's. A pinned id here would be a guess about someone else's endpoint as well as about the job: point Codey at a gateway that serves neither, and every spawn of that preset fails before its first turn. The models it may choose between are in its own system prompt: Codey asks the endpoint what it serves at startup — `GET /v1/models`, the same route the picker uses — and lists them newest first, with the rule that the newest is the one to reach for unless you asked for another or it has already failed this session. An endpoint that does not answer that route simply gets no listing, and the agent is told that naming no model runs the subagent on whatever the session is using. The list is read once, because it sits in the cached prefix and a prompt that changes mid-session throws the cache away. Subagents do not get this paragraph — they have no such choice to make. `output_config.effort` is a per-model capability, not a universal parameter. Haiku 4.5 rejects a request carrying it, and `xhigh` did not exist before Opus 4.7 — so a request for a level its model does not take goes out without it, rather than failing with a 400 that reads like a bug in Codey. Where that happens to a subagent the answer says so outright (`claude-haiku-4-5 takes no effort level, so "low" was not applied`) instead of letting the parent believe it got what it asked for, and the effort pill in the status line reads `not on Haiku` rather than claiming a setting that is not in force. A model this build has never heard of — a gateway serving something else, a family released after it — is treated the same way: the level is left off. The two mistakes are not symmetric. Sending effort to a model that lacks it is a 400 that ends the run before any work happens; omitting it costs the level and says so in the pill. Gateway decoration is seen past first, so `anthropic.claude-opus-5`, `openrouter/anthropic/claude-opus-5` and `claude-opus-5@20260401` are all recognised as the model they name — for pricing and for the thinking parameter too. Every spawn goes through the ordinary approval box, which shows the preset, the model and the task. It is not rememberable per task — the task text differs every time, so "always this exact one" would never match twice — but "always allow this tool" still applies, for anyone who has decided to trust spawning as such. A subagent's own tool calls go through the same policy, and the box says which agent is asking. Four run at once; further spawns queue. Two agents editing one file is not a special case: `edit_file` already refuses a file whose mtime moved since that agent read it, so the second one is told to read it again rather than clobbering the first. A report reaches the parent exactly once, whichever way it gets there. Both paths — `agent_wait` returning it as a tool result, and the front end putting it into the conversation as a message — claim it from the supervisor first, and only one claim wins. Without that, an agent that finished before its `agent_wait` call was even emitted was handed over twice: once as the result, once as a message a turn later, which is the context cost the whole feature exists to avoid. A wait that loses the claim answers with a line saying where to find the report instead of repeating it. **The report comes back on its own.** When a subagent finishes, its report is appended to the main conversation as a user message and the main agent starts a turn on it — you do not have to type anything. It is held back only while a turn is already running, while an approval is on screen, while a `!` or `$` command is running, or while you have text in the prompt box: a turn starting under your fingers would blur the input and eat what you were typing. It goes up the moment that clears. A failed or killed agent is reported the same way, so the parent is never left waiting on a result that is not coming. ### The agents sidebar `ctrl+b` opens a 30-column pane on the right and gives it the keyboard; the transcript reflows into what is left rather than being covered by it. Open and focused are separate things. The pane answers every key while it has focus — that is what lets `k` be a kill rather than a letter — so **click the prompt to type again**, and the pane stays where it is. Click a row to hand the keys back. `ctrl+b` always moves towards the pane: it opens a closed one, focuses an open one, and only closes a pane that already has the keys. Which one is live is visible in two places: the heading carries the accent and the prompt frame goes dim, or the other way round. ``` ⏺ Two of those are independent, so I will farm │ Agents 2 ⏵ them out. │ │ ❯ ✳ a1 plan $0.23 ⏺ SpawnAgent(preset=explore, task=…) │ Opus · 2m04s · 6 turns ⎿ started a2 (explore) on claude-haiku-4-5, s…│ ● a2 explore │ Haiku · waiting · 1 turn │ │ ↑↓ move · enter open ✢ Deliberating… (8s · turn 5 · esc to interrup…│ p pause · k kill · esc back ╭──────────────────────────────────────────────╮│ │ › ask for a change, /help, ! for a shell ││ ╰──────────────────────────────────────────────╯│ joni ✳ Opus 5 ⏵ 2 agents │ ⏵ approvals on (shift+tab to cycle) · ctrl+b a…│ ``` Each agent is two lines: the status glyph, the id and the preset, with the cost at the right, and under it the model, how long it has been going and how many turns it has taken. The model is the family alone — thirty columns will not hold `claude-haiku-4-5` as well as the numbers — and the full id is in the header of the thread, one keystroke away. | Key | Does | | --- | --- | | `↑`/`↓` | move | | `enter` | open that agent's thread in the main pane | | `esc` | back to your own thread; again to close the pane | | `p` | pause or resume | | `k` | stop it | | click the prompt | type again, leaving the pane open | The statuses are queued, running, waiting — blocked on an approval of yours — paused, done, failed and killed. Pause is checked between turns: the model call and the tool calls in flight finish, and it holds before the next request, so the conversation is never left with a `tool_use` whose `tool_result` never came. Kill cancels immediately, and what the agent already did to the tree stays done — this ends an agent, it does not undo one. Mouse reporting is on, so clicking the agents pill in the status line toggles the pane and clicking a row selects it. `/mouse` turns reporting off and on again, because while it is on the terminal's own text selection is not. A child's usage is priced at the child's model and rolled into the session total, so a haiku agent spawned from an opus session is not billed at opus rates. Every metrics event a child writes carries its agent id, so `.harness/events.jsonl` can be split per agent afterwards. Subagent transcripts last as long as the session does: `-resume` brings back your conversation, not theirs. Subagents belong to the TUI: their reports come back as messages the front end puts into the conversation, and the line-based REPL has nowhere to show four agents at once. `-p` and `-no-tui` runs are not offered the tools at all, and `-no-agents` turns them off for a TUI run too. ## Setup The first run on a terminal opens a full-screen wizard instead of the chat: the mark assembles, the name lights up, and it asks four questions one at a time. "First run" means the config file does not say `"setup": "done"` — the marker the wizard stamps when the last question is answered — rather than that no file exists, so a config written by hand, or by a build with fewer questions in it, still gets asked the ones it is missing. ``` codey setup step 1 of 4 ◆━━━━━━━━━━━━━━━◇───────────────◇───────────────◇ Which model should the agent use? ▌ ◆ Claude Opus 5 $5 / $25 per Mtok ◇ Claude Sonnet 5 $2 / $10 per Mtok ◇ Claude Haiku 4.5 $1 / $5 per Mtok ◇ custom… claude-opus-5 · 1.0M token context ↑↓ move enter next esc back ctrl+c cancel ``` | Question | Answers | | --- | --- | | Model | whatever `GET /v1/models` returns, plus `custom…` for any id | | Effort | the API's own `llm.EffortLevels`: `low` `medium` `high` `xhigh` `max` | | Approvals | ask before every mutating call, or accept them all | | Theme | `ember` `mint` `iris` `mono` | It is the same catalogue and the same pricing table `/model` uses, through the same `GET /v1/models` call, so the two cannot disagree about what a model is called or what it costs. Where they differ is failure: `/model` reports the error and closes, because there is a session to go back to. The wizard cannot do that on a first run, so it falls back to the models this build can price and says so. `custom…` is there because a gateway may serve something no build has heard of. The theme step previews by applying the palette for real — reading a swatch tells you nothing about what a transcript looks like in it — and it recolours the whole TUI, pickers and slider included. Cancelling puts the old one back. Answers land in `~/.config/codey/config.json` (`XDG_CONFIG_HOME` is honoured): ```json { "version": 1, "model": "claude-opus-5", "effort": "high", "approvals": "ask", "theme": "ember", "auto_compact": "on", "setup": "done" } ``` `auto_compact` is the one key neither the wizard nor `/settings` offers a row for — set it by hand, or pass `-no-auto-compact` for a single run. Both screens write the whole file but only touch what they asked about, so it survives them. Set to `"off"`, the conversation is never folded on its own: the window becomes a wall rather than a trigger, and at `-compact-floor` the turn stops before sending a request that would not fit, naming `/compact` and `/clear` as the ways on. The prompt is refused *before* it joins the history, so compacting and asking again sends it once rather than twice. `/compact` keeps working throughout — the setting governs the automatic fold, not the manual one. Anything other than `"on"` or `"off"` is dropped, and a missing key means on, so a config written before this existed does not suddenly start stopping the agent. Nothing in that file is required, and none of it wins an argument with a flag: precedence is **flag, then config, then the built-in default**, so `-model claude-sonnet-5` is still a one-run override. `accept-all` saved as a default is honoured, but `-yolo` can only turn approvals off, never on — a file should not be able to quietly grant the agent more than the command line did. `-no-auto-compact` runs the same way round: the flag can only turn the automatic fold off, and a config that never mentions it leaves it on. A value this build does not recognise is dropped rather than forwarded to the API, and effort is checked against `llm.EffortLevels` rather than a second copy of the same five strings. `/setup` reopens the wizard at any time. Finishing applies the answers through the same setters `/model` and `/effort` use, so a setting chosen in the wizard and the same setting chosen from the prompt cannot come out different — the wizard just reports them in one line instead of three. It is refused mid-turn: changing the model while a turn is in flight would finish it on a different model than it started. A config file that cannot be parsed is reported and then ignored, and is never overwritten until you finish the wizard. `/model` and `/effort` change the running session and say "from the next turn on"; they do not write to the file. The two screens that do are the wizard and `/settings`. ### Where Codey keeps things Two directories: one global, one per repository. The second sits in your working tree and is git-ignored, because sessions and an event log are yours rather than the project's. | Path | What | | --- | --- | | `~/.config/codey/config.json` | the four answers, written by the wizard and `/settings` | | `/.codey/sessions/` | saved conversations, which is what `-resume` reads | | `/.codey/events.jsonl` | the event log METRICS.md is built from | Codey was called `harness` until recently, and both of those used to live under that name. Neither rename costs you anything. The config is read from `~/.config/harness/config.json` when there is nothing at the new path, so a setup you have already done still counts and the wizard stays shut; that is a read, and the old file is left where it is for an older build to keep using. The per-repository directory is instead moved once, on the first run in a clone that still has a `.harness/`, and Codey says so when it does — the event log is append-only, so leaving half a repository's history under the old name would quietly corrupt the numbers rather than preserve them. A clone that somehow has both keeps the newer one and leaves `.harness/` alone. ### /settings `/settings` is the same four settings without the interview: one row each, changed in place, applied as you move. ``` Settings ↑↓ row · ←→ change · esc done ❯ Model Opus 5 $5 / $25 per Mtok Effort high default Approvals Ask me first recommended Theme ember claude-opus-5 · enter to pick from anthropic saved to ~/.config/codey/config.json ``` `←→` cycles a row and applies it there and then — the theme repaints under the panel, the way it previews in the wizard — and `esc` closes and writes the file. There is no cancel, because there is nothing left to confirm: the session already has every change. A panel that was only read leaves the file alone, and so does a value cycled back to where it started. The rows come from the same option lists the wizard's steps are built from, so neither screen can invent a name for a theme or a sentence about an effort level the other does not use. The model row is the exception: its choices come from the endpoint rather than from this build, so `enter` hands over to the `/model` picker and lands back here once you have chosen. What the panel shows is the *session*, not the file — `/effort`, `shift+tab` and `-yolo` all change a setting without the file hearing about it, and a panel that disagreed with the status bar two lines below it would be worse than useless. Only the rows actually touched are written back, which is what keeps a `-yolo` run from quietly saving "accept everything" as the default for every run after it. And the setup marker is *not* stamped here: changing a theme is not answering the setup questions, so someone who has only ever used this panel is still offered the wizard. The whole thing is `↑↓` and `enter`, `esc` to go back. Animations collapse to their final frame under `NO_COLOR`, a `dumb` terminal, or a window under 60×20, and any key skips the one playing. ## Tools | Tool | Approval | Notes | | --- | --- | --- | | `read_file` | automatic | Numbered lines, `offset`/`limit` for paging | | `glob` | automatic | Newest first; `*.go` matches at any depth | | `grep` | automatic | ripgrep when present, pure Go otherwise, identical output either way | | `edit_file` | prompts | Requires a prior read; refuses ambiguous matches; returns a diff | | `write_file` | prompts | Whole files only; separate from `edit_file` so the metrics stay honest | | `bash` | prompts | Own process group, killed whole on timeout; output capped head and tail | | `ask_user` | never | Puts a multiple-choice question to you and waits — see [questions](#questions) | ## Skills A skill is a directory of instructions the agent pulls in when it is relevant: ``` ~/.agents/skills/commit-helper/SKILL.md every repo ./.agents/skills/commit-helper/SKILL.md this one, checked in ``` ```markdown --- name: commit-helper description: Use when writing commit messages for this repo --- Read the last twenty commits for tone before writing anything. Conventions are in references/conventions.md. ``` The directory name is the skill's name, and a `name:` that disagrees with it is a load error. Only the description reaches the model until the skill is invoked, so it is what decides whether the skill fires at all; the body can be as long as you like. Two ways in, one way to load. The agent calls the `skill` tool when a description matches what it is doing; you can force one with `/commit-helper fix the flaky test`, which the model answers by making the same call. `/skills` lists what was found, project shadowing personal on a name collision. Skill directories are readable by `read_file`, `glob` and `grep` even though they sit outside the working root, so a skill can ship reference files and point at them. They are not writable by anything: `edit_file`, `write_file` and `bash` keep the working root as their only root. A skill is input, not a work product. Relative paths still anchor to the working root, so a skill pointing at its own files is read with the directory the `skill` tool prints joined on. If the model forgets, the "no such file" it gets back names the skill directories, which is usually enough to recover on the next turn. A shadowed skill's directory is not readable — only the skills actually in play widen anything. Discovery happens at startup. The name-and-description listing goes in the cached prompt prefix, so re-reading the directory each turn would cost a cache miss on every turn for a file that never changes. Restart to pick up a new skill. `-no-skills` turns the whole thing off; the eval runner never discovers skills at all, so METRICS.md is unaffected by what is in your home directory. ## Evals ```bash ./evals/selftest.sh # no API calls: checks the tasks measure something go run ./evals -n 3 # the real thing ``` Ten tasks, a fresh temp directory per run, full transcripts under `evals/runs//`. See [METRICS.md](METRICS.md). ## Safety Three layers, in increasing order of how much they actually protect you. **Path jail.** Every path a tool is given is resolved with `filepath.EvalSymlinks` and refused if it lands outside the working root, including through a symlink that points out of the tree. Cheap, and it catches a lot. **Approval prompts.** Read-only tools run unattended. `bash`, `edit_file` and `write_file` ask, with "yes / no / always this exact one / always this tool" and session memory. `/allowed` lists what you have agreed to. Shell commands are parsed with `mvdan.cc/sh/syntax` so the prompt can tell you what a command will actually run — `git status; rm -rf ~` is shown as running both `git` and `rm`. **This is not a security boundary**, and Codey does not pretend otherwise: a command containing `$(...)`, backticks, or an interpreter with `-c` cannot be analysed at all, so it is marked opaque and "always allow this exact command" is refused for it. String-matching allowlists for shell commands do not work; this exists to make the prompt specific, not to make the decision for you. **A sandbox.** This is the layer that actually contains a mistake, and the Codey cannot provide it for you. The honest recipe on Linux: ```bash podman run --rm -it \ -v "$PWD":/work:Z -w /work \ -e ANTHROPIC_API_KEY \ docker.io/library/golang:1.26 \ /work/codey -C /work -yolo ``` **Decide about egress explicitly.** The container above has network access, because Codey needs to reach the API — which means the agent's `bash` has it too. That is the line between "broke my repo" and "read my `.env` and made an HTTP request". If that matters for the tree you are pointing it at: - Treat every secret reachable from inside the container as exposed. - Use a token scoped to nothing else, and a clone rather than your working tree. - For real isolation, run Codey on the host and put only `bash` in a network-less namespace. That is not implemented here. `-yolo` skips every prompt. It is correct inside a container or against a throwaway clone, and wrong everywhere else. The eval runner sets it, because it is headless and operates on a temp directory it created. ## Layout ``` cmd/codey flag parsing, wiring, REPL internal/llm Messages API: types, HTTP, SSE, pricing internal/agent turn loop, conversation state, compaction, sessions internal/tools the tool registry and the tools internal/skills discovery and parsing of SKILL.md internal/config saved defaults: model, effort, approvals, theme, compaction internal/approval allow / deny / ask, with shell analysis internal/ask questions the agent puts to you mid-task, and the tool for it internal/metrics JSONL event log internal/ui line-based terminal rendering internal/tui full-screen Bubble Tea front end, and the setup wizard evals task suite and runner ``` Two rules hold the shape: `agent` never imports `ui` or `tui`, so the eval runner can drive the loop headless; and every tool returns `(string, error)` where the error is text the model reads, not an exception Codey swallows. A failed edit is a normal event in the transcript. The TUI obeys the first rule through two one-way hatches. Events reach it via `agent.Observe`, forwarded into the Bubble Tea loop with `Program.Send`, which is safe from the agent's goroutine. Approvals go out the same way and then block the agent on a reply channel until a key arrives, and questions take the same hatch with an answer on the way back. Those channels are buffered and the wait honours the turn's context, so neither side can strand the other.