Quickstart

An AI coding agent reading this: start at For agents, the same loop as exact commands and rules.

Flywheel is a factory for AI coding work: a small Go CLI that plans, dispatches, measures and inspects bounded coding tasks. A lead writes a work order, a cheap disposable worker builds it, machine gauges re-run the work order’s gates on the exact tree, an inspector signs it off, and the whole story is appended to a durable event log you own. No vendor session is the source of truth — the repository files are.

This guide takes you from “I have a binary” to “I have landed one unit through the loop.” Every command in it is real: run them in a fresh git repo and they do what is printed here. A refusal you hit is the tool working, not a bug — the last section tells you each exit code and the exact command that fixes it.

Install it

Download the release for your platform from the Releases page: a zip named flywheel-v<version>-<os>-<arch>.zip (Windows binaries ship as flywheel-v<version>-windows-amd64.exe.zip), plus the checksums.txt beside it. Already have flywheel installed? Run flywheel upgrade instead.

Verify the download before trusting it: the printed hash must match the matching line in checksums.txt exactly. If it does not, the download is corrupt or tampered with — re-download, never install it.

On Linux or macOS, the release publishes four assets — linux-amd64, linux-arm64, darwin-amd64, darwin-arm64 — so the block below derives the one for your machine from uname and uses it for both the checksum and the install:

# V is the latest release tag (or set it to the release you want)
V=$(gh release view --repo suzworx/flywheel --json tagName -q .tagName)
case "$(uname -s)" in Linux) OS=linux;; Darwin) OS=darwin;; *) exit 1;; esac
case "$(uname -m)" in x86_64) ARCH=amd64;; aarch64|arm64) ARCH=arm64;; *) exit 1;; esac
BIN=flywheel-$V-$OS-$ARCH

shasum -a 256 "$BIN.zip"   # compare with checksums.txt
unzip -o "$BIN.zip"
sudo install -m 0755 "$BIN" /usr/local/bin/flywheel

On Windows (PowerShell), the zip holds flywheel-$V-windows-amd64.exe. Extract it into %USERPROFILE%\flywheel and add that directory to your user PATH, so flywheel is callable from any directory:

# $V is the latest release tag (or set it to the release you want)
$V = gh release view --repo suzworx/flywheel --json tagName -q .tagName
Get-FileHash "flywheel-$V-windows-amd64.exe.zip" -Algorithm SHA256
New-Item -ItemType Directory -Force "$env:USERPROFILE\flywheel" | Out-Null
Expand-Archive "flywheel-$V-windows-amd64.exe.zip" -DestinationPath "$env:USERPROFILE\flywheel"
Rename-Item "$env:USERPROFILE\flywheel\flywheel-$V-windows-amd64.exe" flywheel.exe
[Environment]::SetEnvironmentVariable('Path', "$env:USERPROFILE\flywheel;" + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')
$env:Path = "$env:USERPROFILE\flywheel;$env:Path"

Confirm it runs:

flywheel version

When a new release comes out, flywheel upgrade self-updates with the same checksum verification (flywheel upgrade --check prints the current and latest versions first). Before you dispatch anything, flywheel doctor probes the model you configured and tells you whether it is actually reachable — a missing or broken provider shows up there, not as a mysteriously stuck worker.

1. Scaffold the factory

Run flywheel init inside a git repository. It creates flywheel.md (the shared status page) and the .flywheel/ state directory:

added: flywheel.md
added: .flywheel/state.json
added: .flywheel/events.jsonl
added: .flywheel/config.json
added: .flywheel/.gitignore
added: .flywheel/.gitattributes
added: .flywheel/briefs/

factory: <your repo>
  workers: default (opencode openrouter/deepseek/deepseek-v4-flash-0731, max_parallel 4)
  limits: per_host none · budget none · tokens none · breaker none · rate none
  audit: first-article gate off
  enforcement: agent hooks missing — flywheel init --hooks · git hooks: missing — flywheel init --git-hooks · CI audit missing — flywheel init --ci
  view: flywheel (the live floor) · flywheel watch · flywheel next
  agents: none found; worker default uses opencode (openrouter/deepseek/deepseek-v4-flash-0731)
next: flywheel log --task <id> --kind planned --brief <path>

The factory: summary is what the repository is set up with: the workers, the limits, the first-article audit gate, which enforcement hooks are installed (each missing one names the flywheel init flag that adds it), and the commands that show the floor.

The agents: line says which agent CLIs init found on your PATH and which one the default worker uses. init looks for claude, opencode, codex and pi, in that order, and gives the worker the first one found: claude gets claude-sonnet-5, opencode gets the model above, codex gets gpt-5-codex, and pi gets anthropic/claude-sonnet-5. When it finds none, the worker uses opencode. flywheel init --adapter claude|opencode|codex|pi chooses the adapter without detecting (any other name is a usage error, exit 2), and --model still sets the model. This only applies when .flywheel/config.json is new: init never rewrites an existing one.

flywheel config validate checks each worker’s model and fallback models against flywheel’s list, so a lead runs it before a dispatch to catch a typo early. A claude model must be one of claude-fable-5-1, claude-opus-5-5, claude-sonnet-5, claude-haiku-4-5-20251001 or the CLI aliases fable, opus and sonnet (the claude CLI has no haiku alias). For an unknown model, the problem names the closest known ID. An opencode model must be <provider>/<model>, a codex model must not contain spaces, and a pi model must be <provider>/<id>, optionally with a :<thinking> suffix (for example anthropic/claude-sonnet-5:high). When a new model comes out before flywheel lists it, set "allow_unknown_model": true on that worker. Loading a config never checks the list, so every other command still runs with a model flywheel does not know yet.

What each piece is for:

Your .gitignore matters: the state files must be committed — the log is the only source of truth — while .flywheel/runs/ (the transcripts) stays ignored. flywheel init warns you when git would hide a state file; it prints the exact !.flywheel/... lines to add so the log, state and config are tracked again. Commit the log with merge=union, so parallel branches’ appends merge without conflicts: flywheel init writes that into .flywheel/.gitattributes, and an older repository adds the line .flywheel/events.jsonl merge=union to .gitattributes. flywheel doctor warns while the log is untracked or lacks merge=union.

2. Write the first work order

A work order is one text file with a header and a goal. Create .flywheel/briefs/hello.txt:

owns: hello.txt (new)
needs: none
gate: test -f hello.txt
gate: grep -q "Hello from flywheel" hello.txt

# TASK: hello — write a greeting file

Write hello.txt containing the exact line "Hello from flywheel".

At most one write per response and at most 120 lines per write; batch read-only calls together.

## Checks

Report the file you wrote, the commands you ran, and their real exit status.

Each header line sets one rule:

Check the brief, then record it as a planned event:

flywheel lint .flywheel/briefs/hello.txt
flywheel log --task hello --kind planned --brief .flywheel/briefs/hello.txt

flywheel lint exits 0 only when the header is complete — an owns: line, at least one gate: line, a # TASK goal and a ## Checks section. Each line it prints is labelled problem: or warning: and a count line ends the output; warnings do not change the exit code. flywheel log appends a planned event. The brief is hashed at dispatch, so editing it afterwards is detectable (rule T1).

3. Dispatch a worker

flywheel run dispatches the task. It reads .flywheel/config.json for the worker — a real config names the worker, its adapter and its model:

{
  "version": 1,
  "workers": [
    {
      "name": "default",
      "adapter": "opencode",
      "model": "openrouter/deepseek/deepseek-v4-flash-0731",
      "max_parallel": 4
    }
  ]
}

There are five adapters: opencode, claude, codex, pi, and sim. The first four call a real provider. pi is the pi coding agent (npm install -g @earendil-works/pi-coding-agent); its models are provider/id, e.g. anthropic/claude-sonnet-5. A claude worker may set permission_mode (acceptEdits, the default, bypassPermissions, default, plan or dontAsk) and max_turns (else limits.max_turns, else 200); both are refused on any other adapter. sim needs no provider at all — it replays a recorded run — so if you have no API key you can still drive every command in this guide. What a simulated worker produces is a replay, not real work: it writes no files, so a sim unit’s own gates fail on purpose. Use opencode (or claude, codex or pi) with a reachable model for the file to actually be written; confirm the model first with flywheel doctor.

Dispatch the unit:

flywheel run hello

The CLI prints each event as it records it:

hello r1 dispatched opencode openrouter/deepseek/deepseek-v4-flash-0731
hello r1 started ses_abc123
hello r1 plan recorded
hello r1 report recorded
hello r1 finished rc=0 reason=stop model=openrouter/deepseek/deepseek-v4-flash-0731 steps=2 tokens=285 cost=$0.0040

rc=0 means the worker ran to a clean stop — it says nothing yet about whether the task is correct. That is the next step’s job.

4. Measure it yourself

The worker’s report is a claim, not evidence. flywheel validate re-runs the brief’s gate: lines on the exact tree as it is now and checks that nothing changed outside owns::

flywheel validate hello
validate: brief .flywheel/briefs/hello.txt
hello gate 1: pass (38ms)
hello gate 2: pass (46ms)
hello file hello.txt: 1 lines
hello owns: ok

Two things are happening here that the worker’s own self-report cannot provide:

The lead re-measures because a worker’s claim is not evidence: the gauges are deterministic, the worker is not. flywheel validate exits 0 only when every gate passed and nothing sits outside owns:; anything else is 5.

5. Inspect and land

Measurement is not judgment. A passing validate records readings; flywheel inspect turns them into a verdict. The inspector is a separate session from the worker:

flywheel inspect hello --verdict pass --session <your-session>
hello inspected pass

--session must be a session that was never the worker’s — rule T4 refuses self-inspection: an inspector cannot pass work its own session built, because then the two independent checks (the one that built, the one that judged) collapse into one. inspect also refuses (T3) a pass verdict unless a passing validated reading and a clean owns_checked exist for the same tree the unit actually built — a pass with no readings is refused before it is even recorded. Either refusal exits 6 and names the rule and the fix.

A pass is not the end. The work still has to land, and landing is recorded against a commit:

git add hello.txt && git commit -m "hello: write the greeting"
flywheel land hello --commit <sha>
hello landed <sha>

flywheel land refuses (exit 6, rule T5) unless the task has an inspected pass on record, and refuses (exit 6, rule T9) while the task has untriaged signals. When you verified the unit by hand but the gauges cannot run, use --exception "<evidence>" --session <your session> to land on a recorded exception. flywheel verify --all then checks the whole log against the poka-yoke rules, and flywheel state prints the floor derived from the log. The event log now holds the unit’s whole story — planned, dispatched, started, finished, validated, owns_checked, inspected, landed — and the next head reads it from the files.

What to do when a step refuses

Exit codes follow one convention across the CLI:

Exit Meaning What to do
0 ok nothing
1 error read the message; the command did not complete
2 usage a flag or argument was wrong; run flywheel help <command>
5 gauges failed a gate failed or a change sits outside owns:
6 rule refusal a poka-yoke rule refused the action; the message names the rule and the fix
8 inconclusive a check could not be established (no violation either) — e.g. flywheel verify on a pass whose tree no repository it can see resolves; run it where that tree exists

flywheel run adds its own outcome codes for the dispatch it measured:

Exit Meaning What to do
3 silent no output within the start timeout; check the model with flywheel doctor
4 failed any other non-clean outcome; read the run’s transcript and event
7 stalled the run file stopped growing for the stall timeout

Three refusals a beginner actually hits, and the exact command that fixes each:

A brief with no gate: line. flywheel lint reports it and exits 1, and flywheel run refuses to dispatch it.

lint: .flywheel/briefs/hello.txt: problem: no gate: line
lint: .flywheel/briefs/hello.txt: 1 problem, 0 warnings

Fix: add at least one gate: line to the brief header, then re-lint:

flywheel lint .flywheel/briefs/hello.txt

A pass verdict with no passing reading for the current tree. flywheel inspect hello --verdict pass refuses under T3 until the gauges have recorded a passing validated and a clean owns_checked on the same tree hash.

flywheel inspect: T3: no passing supervisor validated reading for gate 1 on tree <hash> after the latest finished event; run: flywheel validate hello

Fix: run the gauges, then inspect again:

flywheel validate hello
flywheel inspect hello --verdict pass --session <your-session>

A changed file outside owns:. flywheel validate exits 5 and prints the offending paths.

hello owns: outside hello.txt

Fix: either move the file inside the unit’s boundary (change the brief’s owns: and re-record it with flywheel log --task hello --kind amended --brief <path> --note <why>), or revert the stray change. Then re-validate:

flywheel validate hello

A refusal is the tool working, not a bug: every one of these is the factory refusing to let an unmeasured or out-of-bounds unit pass. Read the message — it names the rule and the fix.

Where to go next