Vectrum
Sign In
1653 lines · 87.6 KB
# 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-web`, `-no-hooks`,
`-no-diagnostics`, `-no-mcp`, `-no-claude-code-auth`. And the ones a script
written for Claude Code passes, with Claude Code's meaning — see [using it in
place of `claude`](#using-it-in-place-of-claude): `--output-format
text|json|stream-json`, `-c`/`--continue`, `--allowedTools`,
`--disallowedTools`, `--permission-mode`, `--dangerously-skip-permissions`,
`--append-system-prompt`, `--system-prompt`, `--verbose`, `--print`. Every flag
takes one dash or two.

### 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.

`-max-turns` caps one prompt, and by default there is no cap: a turn count is a
bad proxy for the thing it was guarding against. A task is not finished at turn
thirty and unfinished at thirty-one, and a run that stops there has thrown away
a job that was going fine. What bounds an interactive session is the context
window, the bill, and a person who can press esc.

An unattended run has none of those, which is what the flag is for: set it in
`-p` mode and in CI, where nobody is watching and a model stuck repeating one
failing tool call would otherwise repeat it until the money ran out. When a cap
you asked for does trip, the conversation is left intact and the last thing the
model said comes back with the error rather than being thrown away — say what
to do next, or raise it.

`-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. `-no-web`
takes away web access for one run, whatever the config says; like `-yolo` in
reverse, it can only subtract.

## 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
 ▀█▀▀█▀    /help · ↑ earlier prompts · ! shell · shift+tab approvals
```

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. The fourth line is the handful of things
there is no other way to find out, and it costs no height at all: the gopher is
four rows tall and the text was three. It is dropped whole rather than
truncated when the terminal is too narrow for it, because half a sentence of
hints is worse than none. 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. A finished
answer closes with how long it took — `took 1m 30s · 3 turns` — because the
activity line counts those seconds only while the turn runs, and that number
is worth keeping in the scrollback beside the answer it paid for.

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 |
| `ctrl+v` | paste a picture in — see [images](#images) below |
| `shift+tab` | cycle the approval mode |
| `ctrl+b` | open the agents sidebar — see below |
| `ctrl+t` | open a second session in a tab — see below |
| `alt+1`…`alt+9` | go to a tab |
| `ctrl+pgup`/`ctrl+pgdn` | walk the tabs |
| `alt+w` | close the tab on screen |
| `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 |

Interrupting ends the turn, not the session. A tool still running when you
press `esc` is answered with an error saying it was interrupted, and one that
had already finished keeps its result, so the conversation is never left with
a `tool_use` nothing answered — which the API rejects on every request after
it, the next thing you type or a `-resume` weeks later. A session saved in
that state by an older build is repaired when it is reopened.

Commands: `/allowed`, `/clear`, `/close`, `/compact`, `/effort`, `/exit`,
`/help`, `/image`, `/model`, `/mouse`, `/new`, `/resume`, `/settings`, `/setup`,
`/skills`, `/worktree`, and one per discovered skill.

### Images

`ctrl+v` pastes a screenshot into the prompt. What appears in the box is a
marker:

```
╭──────────────────────────────────────────────────────────────────────────────╮
│ › the button is in the wrong place [image 1] can you find the style for it    │
╰──────────────────────────────────────────────────────────────────────────────╯
```

The marker is ordinary text. It can be moved, and deleting it takes the paste
back — only the markers still standing when you press `enter` become pictures,
each one in the message exactly where it stood in the sentence. So a prompt
can point at one of two screenshots, or put a picture between two paragraphs,
without any of that being a separate mode.

No terminal sends image bytes to the program running inside it, so the
keystroke is a signal rather than a delivery: Codey reads the system clipboard
itself, with the same helper you would run by hand — `wl-paste` on Wayland,
`xclip` on X11, `pngpaste` or AppleScript on macOS, PowerShell on Windows. On
Linux that means one of `wl-clipboard` or `xclip` has to be installed; when
neither is, the paste says so rather than failing quietly.

Some terminals keep `ctrl+v` for themselves and paste the clipboard's text
form, which for a screenshot is nothing at all. An empty paste is therefore
taken as an attempt too, and answered by reading the clipboard directly — so
the key works on both kinds of terminal. Dropping an image file on the window
works as well, because that arrives as its path. With no picture on the
clipboard `ctrl+v` pastes the text on it instead, which is what the key always
meant — the textarea's own binding for it never reached the screen.

A path to an image that is still in the prompt when you press `enter` becomes
that image too, wherever it came from. That is the one that always fires:
catching a paste as it happens needs the terminal to bracket it, and not all
of them do — a terminal that writes the picture to a file and pastes the path
as plain keystrokes is caught here instead. A path to something that is not a
readable image is left alone, so a prompt about a file still reads as one.

`/image <path>` attaches a file by name and needs no clipboard helper at all,
which makes it the one that always works — over ssh, in a container, or on a
terminal that swallows the key. Bare, `/image` takes whatever the clipboard
has.

PNG, JPEG, GIF and WebP go up; the format is decided by the bytes rather than
by the extension, and anything over about 3.7MB is refused with its size,
because the API will not take it.

### Tabs

`ctrl+t` — or `/new` — opens a second conversation in a tab. A tab is a whole
session: its own agent, its own history, its own approvals, its own subagents
and its own file under `.codey/sessions`. Nothing crosses between them but the
model you are on and the terminal they share, so a turn running in one tab is
not interrupted, approved or compacted by anything you do in another.

    1 ◍ fix the parser  2 ● read the spec  3 new session

The strip appears when there is more than one tab and goes away when there is
not: a single session looks exactly as it did before, down to the row count. A
tab is named after the first thing asked of it. `◍` means something is running
in there; `●` means it said something while you were somewhere else.

A new tab starts in the working root of the one you opened it from, so a
second conversation about a worktree is in that worktree. `alt+w` or `/close`
closes a tab — it refuses while something is still running there, and closing
the last one leaves Codey.

`/worktree` branches the work off into a git worktree under
`~/.config/codey/worktrees`. It reads its argument three ways, and which one
you get is visible in what you typed:

    /worktree dark-mode                 a name — made and moved into, nothing else
    /worktree add a dark mode toggle    a task — named for you, then started on
    /worktree resume                    a chooser of the trees that already exist

One word is a name and is used as typed, so the quick case never waits on a
network call. A phrase is a description of the work: a small model turns it
into a name, the tree is made, and the agent is then set to work on it in the
new directory. Whatever the model answers is slugified and bounded before it
reaches the filesystem, and if the naming call fails the task's own opening
words are used instead — a name is not worth failing the command over.

The tree is made by `~/.config/codey/worktree.sh`, which is written once with a
default and is yours to edit afterwards. Codey never overwrites it, so a script
taught to copy a `.env` or install dependencies keeps doing so. The contract is
two lines wide: the name arrives on stdin, and the absolute path of the tree is
the last line of stdout. The script decides where the tree lands, so moving
them somewhere else is an edit to that file and nothing more.

What moves is the work — what the tools read and write, where `!` commands run,
and where subagents are spawned. What stays is the session: the conversation is
still filed in the checkout you started in, alongside the event log, so one
directory holds the whole history of what was done from it rather than a trail
scattered across worktrees. The tree is recorded in the session, so reopening
that conversation — with `/resume` or `-resume` — puts the work back in it. If
the tree has since been deleted, Codey says so and carries on in the main
checkout.

`/worktree resume` goes the other way: into a tree that already exists, with a
new chat in it. Bare, it opens a chooser of every worktree this repository has
— git is asked, so a tree the script put somewhere unusual is still found —
most recently touched first, with the branch checked out in each. Typing
narrows the list: the match is fuzzy, so `dkmd` finds `dark-mode`, and a
branch name finds its tree too. `/worktree resume <name>` skips the chooser.

The checkout you started Codey in is offered alongside the trees, marked as
such, because once the work has moved there has to be a way back. What you get
in the tree is a *new* conversation, not the old one: the chat on screen is
saved first and stays where it is, and the next turn is filed under a new
session that records the tree — so `/resume` can go back to either. Reach for
`/worktree resume` when you want to work in a tree again, and for `/resume`
when you want a particular conversation back.

`/resume` switches to an earlier conversation without leaving Codey. Bare, it
opens a chooser: one row per saved session, named by the first thing you asked
it, newest first, with how long ago it was and what it ran on. `/resume <id>`
goes straight there. Either way the conversation on screen is written out
first, the chosen one is replayed into the transcript, and the next turn saves
into it.

The list is scoped to the working root, and so is `-resume`: a session recorded
in `/home/joni/test` cannot be opened from `/home/joni/test2`. Sessions are
filed under the root they were made in to begin with, and the root each one
records is checked again on the way out, so a session file copied between
checkouts is refused rather than resumed into a tree where none of the paths it
discusses exist.

### 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, a session, a worktree, 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".

### Earlier prompts

`↑` from the first line of the prompt box walks back through what has been
typed into this checkout before, `↓` walks forward again, and `esc` abandons
the walk. The empty box says so — its placeholder names the key, and sheds
clauses rather than characters as the terminal narrows, since a hint truncated
to `! shell · ↑ earli` advertises nothing while looking like a bug. `ctrl+p` and `ctrl+n` do the same, because they already do in every
other list Codey draws.

The rule about the first line is what lets one key do two jobs without a mode:
anywhere below it, `↑` is moving the cursor through a paragraph you are still
writing, which is what it looks like it is doing. Walking all the way forward
again gives back the half-written prompt the walk started from — losing that is
the one thing a history feature can do that is worse than not existing — and
editing a recalled line ends the walk, because the line is yours now.

A `!` or `$` line and a prompt are different kinds of thing, so they are
offered back separately: `↑` at the shell prompt walks shell commands, and the
prompt aimed at the model never hands you a `go test` to send as prose.

The last few hundred entries are kept in `.codey/history` inside the checkout
Codey was started in — not per session, which would forget the thing you are
most likely to want back, and not one file per machine, which would offer you
the test command of a project you are not in. Two tabs append to it rather than
rewriting it, so neither loses the other's lines. It is inside the state
directory, which `.gitignore` already covers; delete the file and the prompt
box simply stops remembering.

### 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 <id>` 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 budget |
| --- | --- | --- |
| `explore` | finding things, and reporting paths and line numbers | 60 |
| `plan` | working out how a change should be made, without making it | 80 |
| `general` | a self-contained piece of work, edits and tests included | 200 |

The session itself runs uncapped; these are the one place a budget stays, and
they are the reason why. A subagent works alone, several run at once, and the
model that spawned it is not checking on it — so each one stops eventually, at
a number far past what the job takes. Hitting it means the agent is stuck, not
that it was busy, and the pane is the faster way out: `ctrl+b`, then `k`. A run
that did ask for `-max-turns` keeps it as a ceiling over these, because a child
free to sail past the cap would be a hole in exactly the guarantee the flag is
bought for.

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 five questions one at a time,
plus the two that are only sometimes there — the Claude skills question on a
machine that has some, and the herdr one on a machine that has
[herdr](#herdr).
"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 5
 ◆━━━━━━━━━━━━◇────────────◇────────────◇────────────◇

 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 |
| Web search | `none`, Claude's hosted search, or a command on this machine |
| Theme | `ember` `mint` `iris` `mono` |
| Claude skills | leave them alone, use them in place, or copy them once — asked only where there are some |
| herdr skill | install it, or not — asked only where herdr is |

The last two are the steps that are sometimes not there. The rail knows how
many questions this run is actually asking rather than how many the build has,
so a machine with neither gets a five-node rail with no gaps in it, and
`step 6 of 7` on a machine with both counts the questions you are being asked.
The herdr answer is also the one that is not a preference: it is an act,
carried out once, so nothing about it is written to the config. It appears when
`HERDR_ENV=1` or `herdr` is on `PATH`. What it installs, and what happens when
the download fails, is [below](#the-herdr-skill). The Claude skills question is
described under [skills](#claude-codes-skills); "copy them once" is an act in
the same way, carried out as the wizard closes, and it is the mode rather than
the act that is saved.

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",
  "web_search": "custom",
  "search_command": "ddgs text -q {{query}} -m {{count}} -nc",
  "claude_skills": "use",
  "diagnostics": "on",
  "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.
`diagnostics` is the one `/settings` has a row for and the wizard does not
ask about: it defaults to on, it runs a tool already installed on this machine,
and nothing it reads leaves the machine, which is the test every question in
that interview is really asking. 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 answers, written by the wizard and `/settings`; [hooks](#hooks) go here by hand |
| `<repo>/.codey/sessions/` | saved conversations, which is what `-resume` and `/resume` read |
| `<repo>/.codey/events.jsonl` | the event log METRICS.md is built from |
| `<repo>/.codey/history` | what has been typed at the prompt here, which `↑` walks back through |

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 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
   Web search    No web access     default
   Diagnostics   On                default
   Claude skills Leave them alone  default
   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; a PNG, JPEG, GIF or WebP comes back as a picture the model can look at |
| `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 and any [diagnostics](#diagnostics) |
| `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. `background: true` returns a handle instead of waiting |
| `process_output` | never | New output from a background process since you last asked, and whether it is still running |
| `process_kill` | never | Stops a background process and its children; everything is killed when the session ends anyway |
| `todo_write` | never | The working plan for a multi-step task; the whole list is sent each time |
| `context_status` | never | How full the window is, how many turns and what they cost |
| `ask_user` | never | Puts a multiple-choice question to you and waits — see [questions](#questions) |
| `web_search` | prompts | Only with `web_search: custom`; runs `ddgs` on this machine |
| `web_fetch` | prompts | With either web search mode; refuses private and loopback addresses |
| `mcp__<server>__<tool>` | prompts | One per tool of each [MCP server](#mcp-servers) in the config |

## Diagnostics

The agent writes a file and finds out it broke the build three tool calls
later, if at all. The cheapest fix is to put the compiler's own opinion in the
result the model is already reading:

```
⏺ EditFile(path=internal/agent/loop.go)
  ⎿ 4 additions, 1 removal
    …

    diagnostics (gopls): 2 problems
      loop.go:14:2: undefined: fmt.Printl
      loop.go:19:6: declared and not used: n
```

After a successful `edit_file` or `write_file`, the changed file goes to
whatever this machine already has for its language — `gopls`,
`pyright-langserver`, `typescript-language-server` or `rust-analyzer` if one is
on `PATH`, and otherwise the compiler or linter: `go build` and `go vet`, `ruff`
or `pyflakes`, `eslint` or `tsc`, `shellcheck`. Nothing is installed and
nothing leaves the machine.

**A clean file adds nothing at all.** Silence is the good news, and a "0
problems" line on every edit would be a paragraph of tokens per edit saying
that nothing happened. Ten problems are reported and the rest counted, and a
diagnostic about a *neighbouring* file — the helper you have just renamed out
from under — is kept and labelled, because that is the one the agent has no
other way to learn about.

Language servers are started once and kept warm between edits, keyed by the
workspace around the file rather than by the working root, so `/worktree` does
not strand a warm `gopls` behind it. A check that has not finished in two
seconds is abandoned rather than waited for: a tool call that hangs because a
language server is confused is far worse than one that reports nothing. Every
other way this can go wrong lands in the same place — a server that will not
start, that dies mid-session, that answers with garbage, or one on `PATH` that
turns out to be a broken shim — and the answer is always no diagnostics and a
fall through to the linter, never a hung tool call. A server that fails to
start is put on a five-minute cooldown rather than retried on every edit.

It is on by default, which is the one setting here that leans that way: it runs
a tool you already have, against a file you already told the agent to write,
and nothing it reads goes anywhere. `"diagnostics": "off"` in the config, the
`/settings` row, or `-no-diagnostics` for a single run turns it off.

## 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, grouped
by where each one came from:

```
skills: 3
  personal
    /commit-helper  Use when writing commit messages
  claude
    /pdf            Extract text and tables from PDFs
  claude:superpowers
    /brainstorming  Use when exploring an idea before writing a plan
```

One line each, truncated to the width. A description written for another
harness can be three sentences long, and this list is for recognising a skill
you already know about rather than for reading it.

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.

### Claude Code's skills

Codey can also use the skills Claude Code already has on this machine — the
ones under `~/.claude/skills`, the ones its installed plugins ship, and a
project's `.claude/skills`. The setup wizard asks about them, and only when
there are some to offer, because a question about nothing is a question worth
not asking:

| `claude_skills` | What happens |
| --- | --- |
| `off` | they are left alone; Codey reads only its own roots. This is the default |
| `use` | they are read where they lie, on every start, so updating a plugin in Claude Code updates the skill here with nothing to re-run |
| `copy` | they are copied once into `~/.agents/skills`, where they are yours to edit and unaffected by the next plugin update |

Off is the default for the same reason `web_search` defaults to none: a skill
is a page of instructions the model will follow, and it should be loaded
because you said so rather than because a directory happened to exist in your
home directory.

Plugins come from `~/.claude/plugins/installed_plugins.json`, newest install
per plugin, so the stale versions left in the cache are not loaded three times
over. A skill of your own in `~/.agents/skills` wins when it shares a name with
one of Claude's — what you put there is a decision, what arrived with a plugin
is not — and `copy` never overwrites a skill already there. Frontmatter Codey
would otherwise reject is accommodated rather than skipped: a name with
capitals, underscores or spaces in it, or one that disagrees with its own
directory, is normalised into a slug instead of becoming a load error, because
these files were written for another harness and are not yours to fix.
`/skills` lists each one with where it came from — `claude`, or
`claude:superpowers` for a plugin's.

## Web search

The agent's knowledge of a library ends where its training did, and the two
questions it cannot otherwise answer — what does this error message mean, and
what is this API called now — are the ones a search box answers in seconds. The
wizard's fourth question decides whether it gets one, and the honest default is
no: every other capability here stays inside the working tree, and a search
query is written by the model out of whatever the model has read.

| `web_search` | What it does | What it costs you |
| --- | --- | --- |
| `none` | nothing; the tools are not registered and the model is never told they exist | — |
| `claude` | declares Anthropic's hosted `web_search_20250305` tool, which runs on their side of the wire | billed per search by Anthropic; needs an endpoint that offers it |
| `custom` | registers `web_search` and `web_fetch`, which run here | a search command on your PATH |

**`claude`** is a line in the request's tool array and nothing else — no
install, no key, no HTML parsing. The searches come back as `server_tool_use`
and `web_search_tool_result` blocks, which the loop turns into ordinary
`WebSearch(...)` transcript entries so a search is something you can watch
rather than a pause you have to guess at. A hosted search cannot be refused at
the prompt, because it happens inside a request you already approved; the
setting itself is the consent. It also needs the endpoint to implement it, so a
gateway or a local server speaking the Messages API will generally refuse the
whole request — if that happens, switch to `custom`.

**`custom`** runs a command. The default is

```
ddgs text -q {{query}} -m {{count}} -nc
```

which needs the DuckDuckGo CLI (`pipx install ddgs`) and no account at all.
`{{query}}` and `{{count}}` are substituted as whole argv elements and the
command is run without a shell, so a query containing `;` or backticks is a
query rather than a second command.

Note the absence of `-o json`, which is the flag you would reach for and the
wrong one: it does not print JSON, it *writes a file* — into whatever directory
the agent happens to be working in, once per search. The plain listing goes to
stdout and is parsed, and `-nc` keeps the colour codes out of the titles.

Any tool will do, as long as it prints to stdout: a JSON array, one JSON object
per line, a `{"results": […]}` wrapper, or ddgs's own aligned listing are all
read. Titles may be `title`; links `href`, `url` or `link`; snippets `body`,
`snippet`, `description` or `abstract`. Edit the command in the wizard, or set
`search_command` in the config.

`web_fetch` is the other half, because a snippet is enough to choose a link and
never enough to write code against. It resolves the host and refuses loopback,
private, link-local and unique-local addresses, on the original URL and on
every redirect hop — the model invents these URLs, and `169.254.169.254` is a
cloud metadata endpoint. Pages come back as text, capped head and tail like
`bash` output, wrapped in markers that say to treat what is between them as
data rather than instructions. That does not make prompt injection impossible;
it makes it visible in the transcript, which is the most a fetch tool can
honestly offer.

Both tools prompt, and neither is in the read-only set: they read nothing local
and send the model's words off the machine, which is the opposite of what that
set is for. The query and the URL are the detail on the prompt, so what is
leaving is on screen before it leaves. "Always this tool" is the answer most
people give once.

## MCP servers

Codey is an MCP client over stdio. Servers go in the config by hand, in the
same shape Claude Code and Codex use, so an entry can be carried across from
either unchanged:

```json
{
  "mcp_servers": {
    "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] },
    "db": { "command": "my-db-mcp", "env": { "DB_URL": "postgres://localhost/dev" } }
  }
}
```

Each server is started once, when the session opens, in the working root, and
every tool it lists is offered as `mcp__<name>__<tool>` — the name being the
key you gave it, not whatever the server calls itself. Subagents get the same
tools from the same processes. Every call prompts like `bash` does, with
"always this tool" to stop asking. A server that is not installed, does not
answer within thirty seconds or speaks a protocol version Codey does not is
reported at startup and left out; the session goes on without it. A server
that dies later is not restarted, and its tools say so when called.

Only tools are used: not resources, prompts or sampling, and no HTTP
transport. Pictures a tool returns are described to the model rather than
shown. A server's directory is fixed when it starts, so after `/worktree` it
is still serving the root the session began in. `-no-mcp` starts none of them
for one run.

## Hooks

Codey publishes its lifecycle — a session opened, a run started, a tool ran, an
agent stopped to ask you something — so that things outside the process can
watch it. That is a different job from the event stream the TUI draws from:
this one carries no conversation content, no text deltas and no tool output,
because its audience is a sidebar, a notifier, or a script, and what the model
wrote is not theirs to have.

| Event | When |
| --- | --- |
| `session_start` / `session_end` | one conversation, from open to close; a second tab is a second session |
| `run_start` / `run_end` | one prompt, from your message to the model's final answer, tool calls and all |
| `turn_start` | one model call inside a run |
| `tool_start` / `tool_result` | one tool call; the result says whether it failed, not what it returned |
| `blocked` / `unblocked` | waiting on a human: an approval prompt, or a question from `ask_user` |
| `paused` / `resumed` | the loop held at a turn boundary, and let go again |

`blocked` is the one you cannot get any other way. From outside the process an
agent thinking hard and an agent that has been sitting on an approval prompt
for ten minutes look exactly alike: the run has not returned and nothing is
being printed. Only one of them will finish without you.

Hooks in the config run shell commands:

```json
{
  "hooks": {
    "run_end": ["notify-send 'codey' \"$CODEY_SESSION finished\""],
    "blocked": ["notify-send -u critical 'codey needs you' \"$CODEY_MESSAGE\""]
  }
}
```

Each command gets the event twice over: as `CODEY_HOOK_EVENT`, `CODEY_SESSION`,
`CODEY_AGENT`, `CODEY_TURN`, `CODEY_TOOL`, `CODEY_REASON`, `CODEY_MESSAGE` and
`CODEY_ERROR` — always exported, sometimes empty, so a one-liner never has to
test first — and as the same fields in JSON on stdin, for anything that wants a
parser rather than string surgery. They run in the working root, one after
another, on the agent's own goroutine, which is why each is given ten seconds
and then abandoned: a hook that hangs would otherwise be a session that hangs.
Output is swallowed unless the command fails, since the TUI owns the screen; a
failure is written to the event log as a `hook_error` and changes nothing about
the run. An unknown event name is refused at startup rather than ignored,
because a hook that silently never fires is the one bug with nothing to notice.

This is the single setting the wizard never writes. A hook is a command your
machine runs on your behalf, so it gets into the file because you typed it
there. `-no-hooks` turns the lot off for one run.

### herdr

[herdr](https://herdr.dev) is a terminal workspace manager for coding agents: a
background server owns the terminals, panes survive a detach, and a sidebar
shows across every project which agent is working, which is blocked, and which
is idle. Codey reports itself to herdr, automatically — there is nothing to
install and nothing to configure. Run it in a pane and it says so at startup:

```
codey: herdr detected — reporting agent state to pane w1:p1
```

Herdr reads most agents' states off the screen with pattern rules. Codey is
told instead, through the contract herdr documents for agents it does not ship
support for: a process inside a pane inherits `HERDR_ENV=1`, `HERDR_PANE_ID`
and `HERDR_BIN_PATH`, and reports semantic state by running `herdr pane
report-agent`. Being told beats guessing in both directions — herdr never has
to infer anything from what the TUI happened to draw, and the state it could
not see at all, that the agent is sitting on an approval prompt behind a
full-screen view, is stated outright along with what it is waiting for:

```
bash: rm -rf build
asked about Storage, Error handling
```

A subagent's prompt counts. Spawning does not block, so a background agent can
ask for approval long after the agent that spawned it went quiet, and a pane
that called that idle would leave you waiting on an agent that is waiting on
you. Two agents can be waiting at once, and answering one does not clear the
other: the pane goes back to what it was doing only when the last prompt is
answered.

One pane holds one Codey and one Codey can hold several tabs, so the tabs are
reduced to the single state the pane is in: blocked beats working beats idle.
What a person scanning a sidebar needs to find is the agent that stopped and
wants them, and after that the one still going. A paused agent reports idle
rather than blocked — nobody is being waited on, and it has no business in the
list of agents that need attention.

Every report carries Codey's session id, so herdr's pane and agent APIs show
which conversation is running in a pane and a restored one can be pointed back
at it with `-resume`. Herdr restarts an agent by itself only where it knows how
to launch it, which for now means the agents in its own registry; the reference
is reported regardless, because it costs one flag and is the half of the
problem that is ours.

Reporting is a no-op anywhere else. Detection is those three environment
variables and nothing else — no probing, no socket, no cost to anyone who has
never heard of herdr — and all three are required, because a stale `HERDR_ENV`
exported into a shell that outlived its pane is exactly the case where
reporting would go nowhere, slowly, forever. `-no-hooks` disables it too.

When a state looks wrong, `herdr agent list` shows what herdr sees, and the
`hook_error` lines in `.codey/events.jsonl` show what Codey could not tell it.

#### The herdr skill

Reporting is one direction. The other is the agent driving herdr itself —
splitting a pane, starting a server in it without stealing your focus, reading
what another pane printed, waiting until another agent is genuinely blocked
rather than merely slow. Herdr publishes a skill that teaches an agent to do
that, and [the setup wizard](#setup) offers to install it when it finds herdr
on the machine:

```
 Install herdr's skill, so the agent can drive herdr?

 ▌ ◆ yes, install it                            ~/.agents/skills/herdr
   ◇ no
```

Yes downloads [herdr's `SKILL.md`](https://github.com/herdrdev/herdr/blob/master/skills/herdr/SKILL.md)
and writes it to `~/.agents/skills/herdr/SKILL.md`, which is where Codey looks
for personal skills — the same layout herdr publishes and the same one
`npx skills add` writes, so a skill installed by either is found by both. From
the next start it is listed to the agent like any other skill, and invoked the
same way.

It is fetched rather than shipped inside the binary. The file is a manual for a
CLI that is still moving, and a copy frozen into a Codey release would go on
teaching last year's flags long after they stopped working. The cost is that
the install needs the network: when it fails, nothing is written, the wizard
says why, and the answers you just gave are saved regardless — only the
download is lost, and `/setup` offers it again.

Nothing is written unread. What comes back is parsed by the same loader that
will read it at startup, and rejected if it is not a skill Codey can load —
a login page, an error page, or a file whose shape has changed. The write goes
through a temporary file in the same directory, so a failed update leaves the
copy you already had rather than half of a new one.

This is the only question in the wizard that acts rather than records, so it is
the only one where pressing enter through the screens does something: the row
names the exact path before you answer, and the notice afterwards names the
file that was written. Uninstalling is `rm -r ~/.agents/skills/herdr`.

## Using it in place of `claude`

A lot of CI jobs and shell scripts run an agent as `claude -p`. Codey reads the
same flags with the same meaning, so pointing one of them at Codey is meant to
be a matter of changing the binary's name:

```bash
codey -p --output-format json "why does TestFoo fail?" | jq -r .result
echo "summarise the last commit" | codey -p --allowedTools "Bash(git log:*)" "Bash(git show:*)"
codey -c -p "now fix it"                                   # same session
```

| Claude Code flag | In Codey |
| --- | --- |
| `-p`, `--print` | One prompt, then exit. The prompt may follow `-p` — Codey's own spelling — or be a positional argument anywhere on the line, as `claude` takes it, or arrive on stdin when there is no other |
| `--output-format text` | The final answer and nothing else on stdout |
| `--output-format json` | One result object at the end |
| `--output-format stream-json` | One JSON line per message as the run goes, then the result object |
| `--verbose` | The same as `-v`. With `json` it prints every line `stream-json` would have, as one array, which is what Claude Code does; `stream-json` does not need it |
| `-c`, `--continue` | `-resume latest`: the most recent session in this working root |
| `--resume <id>` | Unchanged; the id is a Codey session id, which is what `session_id` in the output holds |
| `--model`, `--max-turns` | Codey's own flags, already spelled the same |
| `--allowedTools`, `--allowed-tools` | Calls to approve without asking, as rules |
| `--disallowedTools`, `--disallowed-tools` | Tools to take away, or calls to refuse, as rules |
| `--permission-mode` | `default`; `acceptEdits`, which lets edits and writes through and still asks before a command — the mode the ACP front end has by the same name; `bypassPermissions`, which is `-yolo`; `dontAsk`, which refuses whatever no rule allows |
| `--dangerously-skip-permissions` | `-yolo` |
| `--append-system-prompt`, `--system-prompt` | Add to the end of Codey's system prompt, or replace it. The session's own agent only: a subagent's prompt is its preset's |
| `--include-partial-messages` | Accepted, with a warning, and ignored |

Without `--output-format`, `-p` prints what it always has: the transcript as it
streams, the tool calls, a usage line — for a person watching, not a parser.
Asking for a format is asking for Claude Code's behaviour, and then stdout holds
the format and nothing else; notices, warnings and any approval prompt go to
stderr.

The shapes are Claude Code's, because the programs reading them were written
against it. The result object carries `type`, `subtype` (`success`,
`error_max_turns` or `error_during_execution`), `is_error`, `result`,
`session_id`, `num_turns`, `duration_ms`, `duration_api_ms`, `total_cost_usd`
and `usage` with the four token counts. In `stream-json` it is preceded by a
`system`/`init` line — working directory, model, tools, MCP servers, permission
mode — then an `assistant` line per response, holding the message as the
Messages API returned it, and a `user` line per batch of tool results. Numbers
are this invocation's: a resumed session reports what the resume cost, not
everything before it.

A run that fails exits 1, whatever the format, and the JSON formats still print
their result object — with `is_error: true`, the error in `errors`, and in
`result` whatever the model last said, or the error itself when it said
nothing. That includes failing before the model was ever asked: no credentials
gives a result object, with an empty `session_id` because there was no session
yet. Check `is_error` rather than `subtype`: it is the field that means
failure in both, where the subtypes for a failure are not guaranteed to agree
between the two. A flag
Codey does not know stops the run before any of this, with exit 2, and so does
a value it cannot use — `--permission-mode plan`, say — with exit 1 and a
message on stderr.

**Who gets asked.** Claude Code never prompts in `-p` mode, and with
`--output-format` neither does Codey when stdin is not a terminal: what no rule
allows is refused, and the model is told so and carries on. That is the case in
CI. At a terminal the prompt still comes up, on stderr. Codey's own `-p`
without a format keeps reading answers from stdin, so `yes | codey -p ...`
works as it always has — except when the prompt itself came from stdin, which
leaves nothing there to answer with.

**Rules.** `--allowedTools` and `--disallowedTools` take Claude Code's rule
syntax, comma- or space-separated, as many as you like after one flag:
`Bash`, `Bash(git status)`, `Bash(npm run test:*)`, `Read`, `Read(./src/**)`,
`Edit(./docs/**)`, `Write`, `Glob`, `Grep`, `WebFetch(domain:example.com)`,
`WebSearch`, `TodoWrite`, `Task`, `mcp__github`, `mcp__github__create_issue`.
Claude Code's names map onto Codey's tools — `Bash` is `bash`, `Read`
`read_file`, `Write` `write_file`, and `Edit` both `edit_file` and
`write_file`, because Claude Code applies its Edit rules to every tool that
edits files. Codey's own names work as well. A rule naming a tool Codey does
not have, `NotebookEdit` say, is a warning, and the rest of the list still
applies.

A rule for a whole tool on the disallowed list takes the tool away: the model
is never shown it. A narrower one — `Bash(git push:*)`, `Read(./.env)` — is
checked at approval time, before anything else, `-yolo` included, and a call it
matches is refused. On the allowed list a rule stands in for the "yes" you
would otherwise have been asked for.

`Bash(prefix:*)` matches word by word, so `Bash(ls:*)` covers `ls -la` and not
`lsof`. A compound command is allowed only when every simple command in it is
covered, so `git status; rm -rf ~` does not ride in on `Bash(git status:*)`, and
anything the [shell analysis](#safety) cannot see through — command
substitution, a variable in command position, an inline `bash -c` script, an
assignment like `PATH=...`, output redirected into a file — is never allowed by
a narrower rule than `Bash`. A deny rule is the other way round: one matching
part is enough, and it looks inside `bash -c` and `eval`, past `sudo`, `env`
and `timeout`, and at `/bin/rm` as `rm`. That catches the command a model
would ordinarily write. It is not a sandbox, for the reason the Safety section
gives.

Path rules read like gitignore, as Claude Code's do: `./` and `/` are the
working root, `//` an absolute path, `~/` your home, and a pattern with no
slash in it — `.env`, `*.pem` — matches a name at any depth. Path rules on
`Read` apply to `glob` and `grep` too, as far as their `path` argument goes.

What does not work:

- `--permission-mode plan`. Codey has no read-only planning mode, and says so
  rather than run a mode it does not have. Ask for a plan in the prompt, and
  `--disallowedTools Edit,Write,Bash` holds the run to it.
- `--input-format stream-json`, and so a conversation fed in over stdin. The
  prompt is text.
- `--include-partial-messages`: there are no `stream_event` lines, only whole
  messages.
- `--mcp-config` and `--strict-mcp-config`. MCP servers come from
  [Codey's config](#mcp-servers).
- `--settings`, `--setting-sources`, `--add-dir`, `--fallback-model`,
  `--session-id`, `--fork-session`, and `--resume` with no id, which in Claude
  Code opens a picker.
- Piping a file in alongside a prompt. `cat log | claude -p "explain"` hands
  Claude Code both; Codey reads stdin only when there is no prompt otherwise,
  because a CI runner can leave stdin as a pipe nobody ever closes, and a run
  that waited on it would hang there for good. Put it in the prompt:
  `codey -p "explain: $(cat log)"`.
- A prompt without `-p`, which in Claude Code starts an interactive session
  with that prompt. Codey ignores it and says so.
- Tool names in the output are Codey's — `bash`, `read_file` — not Claude
  Code's. A script that looks for `"name":"Bash"` in the stream needs to look
  for `bash`.
- The result object has no `permission_denials` or `modelUsage`, and lines
  carry no `parent_tool_use_id` other than `null`: subagents report through a
  tool result rather than in the parent's stream.

## Inside another editor

Codey can be the agent behind somebody else's UI. It speaks the
[Agent Client Protocol](https://agentclientprotocol.com) — JSON-RPC over stdin
and stdout — so a control plane that drives Codex, Claude Code or Cursor can
drive this instead:

```bash
codey agent stdio
```

Nothing but protocol frames goes to stdout in that mode; diagnostics go to
stderr. The connection ends when stdin does.

### T3 Code

[T3 Code](https://t3.codes) compiles its providers in, so there is no Codey
entry to select. There is, however, a binary path on every provider, and its
Grok provider is a plain ACP agent — which means Codey can take that slot with
no change to their source. In **Settings → Providers**, enable Grok, set
**Binary path** to your `codey`, and rename the instance:

```json
"providerInstances": {
  "codey": {
    "driver": "grok",
    "displayName": "Codey",
    "enabled": true,
    "config": { "binaryPath": "/path/to/codey" },
    "environment": [
      { "name": "ANTHROPIC_API_KEY", "value": "sk-ant-...", "sensitive": true }
    ]
  }
}
```

That file is `~/.t3/userdata/settings.json`; the settings panel writes the same
thing. The per-instance environment is how Codey gets a credential without
inheriting whatever the desktop app was launched with — and without it, the
provider card will say Codey is installed and not logged in, which is true.

Threads stream text and reasoning, fill a tool row in as the command talks,
raise an approval prompt you answer in T3 Code's own panel, and stop when you
press stop. The model picker is filled from the endpoint Codey is pointed at.

[Subagents](#subagents) work too. Each one gets a row of its own that opens
when the child starts, fills in as it works, and closes with its report, and
the reports reach the parent as messages exactly as they do in the TUI. A turn
stays open while its children are still working — their output belongs inside
the turn that started them, and a thread that looked finished while four agents
were still running would be lying. `-no-agents` in the launch arguments turns
them off.

What does not work is resuming a thread after a restart: Codey does not
advertise session loading, so T3 Code will not offer it.

The card will say Grok and show the xAI glyph. The display name and accent
colour are yours to set; the icon is not. That is the price of occupying a slot
instead of adding one.

Two invocations exist only for that provider card: `codey --version`, and
`codey models`, which prints whether there is a credential and what the
endpoint serves. Both are shaped for the regular expressions on the other end
rather than for a person.

`docs/superpowers/specs/2026-09-19-t3code-provider-design.md` is the research
this is built on: what every T3 Code provider requires of a binary, and why the
Grok slot is the one that fits.

## 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/<timestamp>/`. 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`,
`write_file`, `web_search` and `web_fetch` ask, with "yes / no / always this exact one / always this tool" and
session memory. `/allowed` lists what you have agreed to. `--allowedTools` and
`--disallowedTools` write the same decisions down ahead of time, in Claude
Code's rule syntax — see [using it in place of
`claude`](#using-it-in-place-of-claude) — and a deny rule there is checked
before everything else, `-yolo` included.

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.

Hooks sit outside all three layers, deliberately: a command in `"hooks"` runs
on every event it is attached to, without a prompt, because you put it in your
own config rather than the agent asking for it. The path jail is what keeps the
agent from adding one — the config lives in `~/.config/codey/`, outside any
working root you would sensibly point Codey at — but the flip side is that
anything able to write that file has arranged to run commands here. `-no-hooks`
is the switch that says no for one run. MCP servers are the same bargain: each
is a program started with your environment when the session opens, and only
its tool calls go through the prompts. `-no-mcp` is theirs.

**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/diag    language servers and linters, and what they say about an edit
internal/skills   discovery and parsing of SKILL.md, Codey's own and Claude Code's
internal/websearch  the search command, the URL jail, HTML to text
internal/mcp     MCP client: starts the configured servers, offers their tools
internal/config  saved defaults: model, effort, approvals, web search, theme, diagnostics
internal/approval  allow / deny / ask, with shell analysis and Claude Code's permission rules
internal/ask     questions the agent puts to you mid-task, and the tool for it
internal/hooks   the lifecycle, published: fan-out, and the config's commands
internal/herdr   reporting agent state to the herdr pane Codey is running in
internal/attach  pictures pasted into a prompt: clipboard, files, markers
internal/metrics JSONL event log
internal/ui      line-based terminal rendering
internal/acp     Agent Client Protocol: the wire, and its vocabulary
internal/serve   Codey as an agent another program drives, over ACP
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 and the ACP front end 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.