Your first run
This page takes you from nothing installed to a merged pull request in your repository. Every step names the exact command to run and what its output looks like when it worked.
It is deliberately not the repository README’s quickstart. That quickstart stands the stack up so Fishhawk can change itself — it validates the spec that already lives in the Fishhawk repository. This page assumes the repository you care about is somewhere else, and that Fishhawk has never touched it.
Before you start
Section titled “Before you start”Fishhawk is pre-alpha. There is no installer, no hosted control plane, and no published runner action — you build the binaries from source and run the backend yourself. That means the baseline below is assumed, not installed for you. Check all six now; a missing one surfaces two sections from here as a confusing failure rather than as a missing dependency.
| You need | Why | Confirm with |
|---|---|---|
| Git | You clone the Fishhawk repository to build from, and Fishhawk drives git in your repository | git --version |
| Go 1.25.0 or newer | Every module in the workspace declares go 1.25/go 1.25.0; an older toolchain refuses the build outright |
go version |
| A container runtime with Compose v2 | make up shells to docker compose up -d for Postgres and RustFS |
docker compose version |
make |
The repository’s loops are Makefile targets | make --version |
The Claude Code CLI on PATH as claude, with ANTHROPIC_API_KEY exported |
The runner spawns that binary for the default claude-code executor and reads that variable for the key |
claude --version |
gh, authenticated |
fishhawk run start --issue shells to gh issue view to ship the issue body inline |
gh auth status |
One nuance on the last row: the issue fetch is best-effort. A missing or
unauthenticated gh prints a warning and the run starts anyway — but the agent
then plans without the issue text, which is rarely what you want. Treat gh as
required in practice even though the code does not fail without it.
If you are missing something here, stop and install it. Nothing later on this page recovers from an absent prerequisite.
Get the binaries
Section titled “Get the binaries”There is no packaged install. No CLI release workflow exists in the repository and no version tag has ever been cut.
The CLI installs with go install:
go install github.com/kuhlman-labs/fishhawk/cli/cmd/fishhawk@latestA go installed CLI carries no release stamp, so fishhawk version falls back
to the version Go recorded in the binary: a pseudo-version such as
v0.0.0-20261015175154-d624cd36df36 for @latest or a commit, or the tag for
a tagged ref. It prints no commit SHA, because module-cache builds carry no VCS
settings. Only the CLI is supported this way: its module pins the sibling credstore module by
pseudo-version and carries no replace directive, which go install requires.
The backend module still carries filesystem replace directives, which
go install refuses, so fishhawkd builds from a clone, and so does the
runner, so the two come from the same tree.
You need three binaries: the CLI you drive the loop with, the runner the CLI
spawns per stage, and fishhawkd — which you need on PATH for the token step
below even though you will run the server through make. To build all three
from a clone instead:
git clone https://github.com/kuhlman-labs/fishhawk.gitcd fishhawkgo build -o bin/fishhawk ./cli/cmd/fishhawkgo build -o bin/fishhawk-runner ./runner/cmd/fishhawk-runnergo build -o bin/fishhawkd ./backend/cmd/fishhawkdexport PATH="$PWD/bin:$PATH"Confirm all three resolve before going on:
$ fishhawk versiondev (<commit>)$ command -v fishhawk-runner/path/to/fishhawk/bin/fishhawk-runner$ command -v fishhawkd/path/to/fishhawk/bin/fishhawkdfishhawk version printing dev plus the checkout commit is correct — a clone
build has no release stamp and a (devel) module version, so the CLI reports
the dev version and the commit Go recorded (-dirty appended when the tree
has uncommitted changes). dev also carries “do not enforce a minimum runner
version” semantics; fishhawkd version prints bare dev, since the backend has
no build-info fallback. fishhawk-runner has no
--version flag; it has a version subcommand (fishhawk-runner version)
that prints JSON with version, git_sha and plan_schema_hash. command -v
is still the check that matters here: it confirms the runner is on PATH,
which is how the CLI finds it.
Packaged installs are tracked under E36 / #1638.
Stand up a backend
Section titled “Stand up a backend”There is no hosted control plane to point at. The only published fishhawkd
artifacts are rolling container images
(ghcr.io/kuhlman-labs/fishhawkd:main and :sha-<short>), and the CLI defaults
--backend-url to http://localhost:8080 (overridable with
$FISHHAWK_BACKEND_URL). So you run it locally, from the same clone:
cp .env.example .env # optional now; GitHub App credentials go here latermake up # docker compose: Postgres :5432, RustFS :9000/:9001make migrate # apply the backend migrations — required before first servemake dev-backend # runs fishhawkd on :8080 in the foregroundLeave make dev-backend running and confirm from another shell:
$ curl -s http://localhost:8080/healthz{"status":"ok","version":"dev","git_sha":"219e3c55","min_runner_version":"dev"}If healthz does not answer, nothing else on this page will work. Skipping
make migrate is the usual cause.
A hosted control plane is tracked under E36 / #1638.
Connect GitHub
Section titled “Connect GitHub”Fishhawk reads issues, pushes branches, opens pull requests and reads review
state through a GitHub App you register on your own account. The authoritative
permission and event table is in
docs/github-app/README.md
under Permissions — follow it there rather than a copy here, which would
drift.
What your decision actually turns on: that document describes three modes, and
a backend on localhost puts you in Mode B — App with OAuth, no webhooks.
Register the App, uncheck the webhook’s Active box, enable Device Flow,
and put the credentials in the .env at the root of the clone (cp .env.example .env first if you skipped that above). These are the variables that carry them
— the App and OAuth blocks of .env.example, uncommented:
FISHHAWKD_GITHUB_APP_ID=123456FISHHAWKD_GITHUB_APP_PRIVATE_KEY_FILE=/abs/path/fishhawk-app.private-key.pemFISHHAWKD_GITHUB_WEBHOOK_SECRET=whatever-you-setFISHHAWKD_OAUTH_CLIENT_ID=Iv1.xxxxFISHHAWKD_OAUTH_CLIENT_SECRET=xxxxFISHHAWKD_OAUTH_CALLBACK_URL=http://localhost:8080/v0/auth/github/callbackThe Makefile includes and exports .env, so restart make dev-backend to pick
these up. FISHHAWKD_GITHUB_WEBHOOK_SECRET only gates POST /webhooks/github,
which Mode B never receives; leaving it unset costs you a startup warning and a
503 on that route, both expected here.
Mode B gives you UI sign-in and CLI-driven run dispatch; it does not deliver GitHub events to your backend. That is the mode this guide’s CLI-driven path is written for — you drive each gate by hand instead of waiting for a webhook to advance the run.
One consequence to keep in mind for the merge step: without webhooks, the
pull_request-closed event that settles a run to completed never arrives. The
merge still lands on GitHub; the run row stays open. That is expected in Mode B,
not a failure.
Issue an operator token
Section titled “Issue an operator token”Every CLI call that touches a run carries a bearer token. Issuing one is a
fishhawkd subcommand — not a fishhawk CLI verb. The CLI’s token
subcommands are only login and list:
$ fishhawk token issuefishhawk token: unknown subcommand "issue"Issuance writes a row to the backend’s database, so it needs a database URL,
not a backend URL. Set it explicitly to the same value make uses:
export FISHHAWKD_DATABASE_URL='postgres://fishhawk:fishhawk@localhost:5432/fishhawk?sslmode=disable'fishhawkd token issue --subject "$(whoami)"fishhawkd token issue: applying default operator scope setfhk_<40-odd-opaque-characters>issued token id=<uuid> subject=<you> scopes=[read:runs read:audit write:runs write:approvals write:stages write:deploy write:campaigns read:audit-export]The middle line is the token — it is printed once and not recoverable. Export it, along with the backend URL, so the rest of this page’s commands pick both up without flags:
export FISHHAWK_TOKEN=<the-token-printed-above>export FISHHAWK_BACKEND_URL=http://localhost:8080If you have not built fishhawkd onto PATH, the equivalent from inside the
clone is go run ./backend/cmd/fishhawkd token issue --subject "$(whoami)",
with the same FISHHAWKD_DATABASE_URL exported.
Scaffold your repository
Section titled “Scaffold your repository”Now switch to the repository you actually want Fishhawk to work on. fishhawk init writes .fishhawk/workflows.yaml from an autonomy preset, ensures the
AGENTS.md + CLAUDE.md bridge, and runs a preflight.
The preset is a real decision, not a default to skim past. It sets how much judgment the operator agent may exercise on your behalf:
lowdelegates nothing — every judgment point pages you.mediumdelegates plan approval, fix-up passes and stage retries under named conditions; waiving a reviewer concern and merging stay yours.highadds waive and merge to that set.
There is a second choice alongside the tier: the repository shape, set with
--shape. The default app assumes a test entrypoint. A config- or docs-only
repository has none, so pass --shape config-only: the implement stage then
omits the verify command, drops the tests-added requirement, keeps the merge
CI check, and raises the changed-files cap so a docs reorganisation fits.
This walkthrough uses medium, which is the shipped default and the tier the
rest of this page’s gate sequence assumes:
cd /path/to/your-repofishhawk init --preset mediumThen open .fishhawk/workflows.yaml and replace the placeholders. The preset is
a starting document, not a finished configuration:
executor.verify.commandon theimplementstage ismake test. This is wrong for almost every repository that is not Fishhawk itself. Change it to your project’s test entrypoint, or delete the wholeverify:block if you have none. The runner executes this command in a fresh worktree after the agent exits, so gitignored build output and downloaded dependencies will not be there.constraints.forbidden_pathsandmax_files_changedare Fishhawk’s own boundaries. Set them to yours.budgets[0].limit_usdis an advisory weekly ceiling of$50. It emits an alert, never blocks a run.- The commented
applies_toandescalationsstarters are inert until you uncomment them.
You do not need to fill in a reviewer handle. The approval gates use the
forge-neutral approvals predicate — count: 1 with not: [author, agent],
meaning one approval from somebody who is neither the change’s author nor an
agent identity. There is no repository-specific name to substitute.
Validate the spec
Section titled “Validate the spec”fishhawk doctor --spec-only--spec-only runs the environment-free rungs — schema validity,
execution-path coverage and the charter document (an absent charter is a
warning, not a failure) — and skips every Docker, backend, token, MCP, git and
gh check. It is the right check for a freshly scaffolded repository, because
it passes or fails on your spec alone. A non-zero exit prints which rung failed.
Open your first run
Section titled “Open your first run”Why --runner-kind local
Section titled “Why --runner-kind local”fishhawk run start takes --runner-kind, documented as
github_actions | local. Use local. The github_actions value is not usable
from a repository other than Fishhawk’s own today, for two independent reasons:
- There is no published runner action. Fishhawk’s own dispatch workflow
invokes the runner as
uses: ./runner— a repository-local path that resolves only inside a checkout of the Fishhawk repository. An external repository would need the published composite action the workflow’s own comment names, and no version tag has ever been cut. - The Actions path needs a publicly reachable backend. That workflow
resolves its backend URL to
vars.FISHHAWK_BACKEND_URLorhttp://localhost:8080and calls it from a GitHub-hosted runner, wherelocalhostis the runner itself — not your machine. The backend you stood up above is not reachable from there.
Both gaps are tracked under E36 / #1638, alongside the packaged install and the hosted control plane.
What local means for you
Section titled “What local means for you”Under runner_kind=local the backend never spawns anything. It cannot: the
local runner is host-spawned by design, so every agent stage parks at
awaiting_host_dispatch and waits for you. fishhawk runner start is the
spawn — a per-stage subprocess, not a daemon you leave running. It resolves the
runner binary in this order: --runner-binary, then $FISHHAWK_RUNNER_BIN,
then a PATH lookup of fishhawk-runner. Putting bin/ on PATH earlier
already satisfies the third.
If you forget this, the symptom is a run that looks stuck with nothing in any log. See Troubleshooting.
Start the run
Section titled “Start the run”fishhawk run start --repo <owner/name> --workflow feature_change --issue <N> --runner-kind localIt prints the run’s fields, including the id you will use for every gate:
id: 3f1c9a2e-4b7d-4c81-9a55-0e6b2d8f1c34repo: <owner/name>workflow_id: feature_changestate: pendingrunner_kind: localWalk the gates
Section titled “Walk the gates”Five gates stand between the run you just opened and a merged pull request.
Each is named below with the exact invocation that passes it, and where a gate
has no fishhawk CLI verb, that is said plainly rather than glossed.
Every gate needs a stage id, which fishhawk run status does not print —
it renders the run, not its stages. Read them from the API:
curl -s -H "Authorization: Bearer $FISHHAWK_TOKEN" \ "$FISHHAWK_BACKEND_URL/v0/runs/<run-id>/stages"Two curl calls below post to the host-dispatch marker. That endpoint is what
flips a parked stage from awaiting_host_dispatch to dispatched, so
dispatched reliably means “a spawn attempt exists”. The CLI’s runner start
does not post it — the MCP host-spawn verbs do — so on a pure-CLI path you post
it yourself immediately before spawning.
Gate 1 — run the plan stage
Section titled “Gate 1 — run the plan stage”Mark the plan stage dispatched, then spawn the runner against it:
curl -s -X POST -H "Authorization: Bearer $FISHHAWK_TOKEN" \ "$FISHHAWK_BACKEND_URL/v0/runs/<run-id>/stages/<plan-stage-id>/host-dispatch"
fishhawk runner start \ --run-id <run-id> \ --stage-id <plan-stage-id> \ --workflow feature_change \ --stage plan \ --working-dir /path/to/your-repoThe agent writes a plan — a scope, an approach, a verification strategy — and
posts it as a rendered comment on the originating issue. Under the medium
preset two agent reviewers read it first and surface advisory verdicts; they do
not decide. You do.
Gate 2 — approve or reject the plan
Section titled “Gate 2 — approve or reject the plan”This gate has a CLI verb. Read the plan on the issue, then either:
fishhawk plan approve <run-id> --reason "Scope matches the issue; the migration is in scope.files."or send it back:
fishhawk plan reject <run-id> --reason "Wrong fork: change the storage layer, not the handler."Both take the run id and resolve the plan stage themselves. On reject,
your reason is what the next planning pass reads, so make it specific — a
reject without --reason warns you that the audit row will carry an empty
comment and the requester will be guessing.
On approve, your reason is binding: it is injected verbatim into the
implement agent’s prompt as conditions it must follow. It does not add anything
to the plan’s scope — naming a file path in the prose will not put that file in
scope.
Gate 3 — dispatch the implement stage
Section titled “Gate 3 — dispatch the implement stage”Same two-step shape as the plan stage, against the implement stage id:
curl -s -X POST -H "Authorization: Bearer $FISHHAWK_TOKEN" \ "$FISHHAWK_BACKEND_URL/v0/runs/<run-id>/stages/<implement-stage-id>/host-dispatch"
fishhawk runner start \ --run-id <run-id> \ --stage-id <implement-stage-id> \ --workflow feature_change \ --stage implement \ --working-dir /path/to/your-repo \ --base-branch mainThe agent implements against the approved plan, the runner executes your
verify.command against the committed tree, feeds a test failure back for a
bounded fix loop, then pushes a branch and opens a pull request. Watch progress
with fishhawk run status <run-id> or fishhawk audit tail <run-id>.
Gate 4 — review the pull request
Section titled “Gate 4 — review the pull request”This gate has no fishhawk CLI verb. It is a forge action. The review
stage’s executor is a human and its approval predicate is the same
count: 1, not: [author, agent]. You satisfy it by approving the pull request
under your own GitHub identity:
gh pr review <pr-number> --approve --body "Diff matches the approved plan; verify command is green."Agent reviewers will have posted advisory verdicts on the diff, including a
scope-drift flag if the change wandered outside the plan’s scope.files. They
are input to your decision, not a substitute for it.
Gate 5 — merge
Section titled “Gate 5 — merge”This gate has no fishhawk CLI verb either. The merge is an audited
operator declaration, exposed as a REST endpoint and as the fishhawk_merge_run
MCP tool. On a pure-CLI path, post the verdict:
curl -s -X POST -H "Authorization: Bearer $FISHHAWK_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"verdict":"Reviewed and approved; merging."}' \ "$FISHHAWK_BACKEND_URL/v0/runs/<run-id>/merge"That records a merge_verdict_recorded audit entry and queues the squash merge
through the same seam the delegated path uses. Repeating the post never duplicates the verdict
entry, and it is safe to repeat: the endpoint reads the merge state first, so a
pull request that has already merged comes back as already_merged with no
second merge queued. It requires
write:approvals, which the default operator scope set from your token
includes.
The endpoint does not wait for the merge to land. In Mode B, with no webhook
delivery, the pull_request-closed event that would settle the run to completed
never arrives, so the run row stays open after GitHub merges the branch. Confirm
the merge on the forge:
gh pr view <pr-number> --json state,mergedAtThat is one issue, driven through five gates, to a merged pull request.
Make the check enforce
Section titled “Make the check enforce”Throughout the run Fishhawk posts a fishhawk_audit_complete Check Run on the
pull request. It carries the audit verdict: the plan is on file, the trace
bundle shipped, the agent review landed. What it does not do, on its own, is
stop a merge. A Check Run is a report until the repository’s branch protection
makes it a required status check, and Fishhawk cannot make it one for you — the
App installation holds no administration: write permission.
Until you add it, gate 5 has a real hole. fishhawk_merge_run queues a GitHub
auto-merge, and an auto-merge on a branch that does not require the check will
fire as soon as the other protections clear — including while the agent review
verdict is still pending. The audit trail records what happened; nothing stopped
it.
Add the check to a repository ruleset on your default branch:
gh api -X POST "repos/<owner>/<repo>/rulesets" --input - <<'JSON'{ "name": "fishhawk audit gate", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, "rules": [ { "type": "required_status_checks", "parameters": { "strict_required_status_checks_policy": false, "required_status_checks": [ { "context": "fishhawk_audit_complete" } ] } } ]}JSONIf you already run branch protection through a ruleset, add the context to that one instead of creating a second — two rulesets both requiring the check is harmless but confusing to read later.
A required check is not automatically an enforced one. A ruleset can carry
bypass_actors, and classic branch protection with enforce_admins: false
exempts every repository admin. Either one is a legitimate escape hatch and
either one means somebody can merge past the gate.
Confirm what the forge actually enforces rather than assuming the API call took:
fishhawk doctor --repo <owner>/<repo>The merge gate enforced rung reads your branch protection and reconciles it
against the check Fishhawk publishes. It reports one of three things:
- ok — the check is required on the default branch, naming each protection source that requires it and that source’s own bypass condition.
- warn, not required — the check is published and nothing enforces it. The remediation names the exact context to add.
- warn, unknown — the question could not be settled, with a reason. The App
installation may predate the
administration: readpermission, or the rulesets endpoint may be unavailable on your GitHub Enterprise version. This is not evidence the check is unrequired; it is evidence the doctor could not look.
Both warnings are non-fatal to the doctor’s exit code. If you have decided this repository does not need the gate, you stay informed and unblocked.
Troubleshooting
Section titled “Troubleshooting”The run looks stuck and no runner log exists
Section titled “The run looks stuck and no runner log exists”Almost always the parked-stage case, and the failure mode this guide’s own
--runner-kind local choice makes likeliest: the stage is sitting at
awaiting_host_dispatch waiting for a fishhawk runner start you have not
issued. Check the raw stage state:
curl -s -H "Authorization: Bearer $FISHHAWK_TOKEN" \ "$FISHHAWK_BACKEND_URL/v0/runs/<run-id>/stages"A "state": "awaiting_host_dispatch" means the backend wants that stage
executed but cannot spawn it. Post the host-dispatch marker and spawn the runner
as in Gate 1. This is not an error state — it is
the local runner working as designed.
Missing or invalid token
Section titled “Missing or invalid token”Symptom: a 401 or 403 from any CLI call that touches a run. --token
defaults to $FISHHAWK_TOKEN and silently accepts empty, so the first question
is whether the variable is populated at all. Check that without printing the
token — it is a live bearer credential, and a terminal transcript, a captured
log or a shared screen keeps whatever you echo into it:
if [ -n "${FISHHAWK_TOKEN:-}" ]; then printf 'FISHHAWK_TOKEN: set, %s characters\n' "${#FISHHAWK_TOKEN}"else printf 'FISHHAWK_TOKEN: unset or empty\n'fifishhawk run list --backend-url "$FISHHAWK_BACKEND_URL"An unset or empty verdict explains a 401 on its own — -n is false for
both, and the CLI accepts either without complaint. If it reports a length and
the call still fails, the value is not the thing to inspect: reissue below
instead.
A 403 insufficient_scope names the scope it wanted. Issue a fresh token with
the default operator scope set:
fishhawkd token issue --subject "$(whoami)"A token minted against a different database than the one the running backend
uses will authenticate as unknown — confirm FISHHAWKD_DATABASE_URL matched
what make dev-backend is serving.
fishhawk doctor exits non-zero
Section titled “fishhawk doctor exits non-zero”Run it again without --spec-only to see which rung failed and whether it is a
spec problem or an environment one:
fishhawk doctor --working-dir /path/to/your-repoThe full run adds Docker, backend-reachability, token, git and gh checks on
top of the spec rungs. If the spec rungs pass and an environment rung fails,
the scaffold is fine and the local setup is not. The verify command rung is
skipped by default — doctor will not execute a command string out of a
repository you have not reviewed unless you ask with --run-verify-command.
The plan stage times out
Section titled “The plan stage times out”The medium preset caps a plan stage at max_runtime: 15m, under a
workflow-wide max_stage_runtime: 30m. A timeout is recorded as a stage
failure; read what happened before retrying:
fishhawk audit list <run-id>fishhawk run retry <plan-stage-id>run retry takes a stage id, not a run id. If it times out repeatedly with
no agent output, check that claude is on PATH and ANTHROPIC_API_KEY is
exported in the shell you ran fishhawk runner start from — the runner spawns
that binary and reads that variable, and neither is inherited from the backend.
Where to go next
Section titled “Where to go next”This page covers first contact only. Driving runs day to day — fix-up passes, reviewer concerns, scope amendments, recovering a stranded stage — is the operator guide’s subject. For why each gate exists rather than how to pass it, read Concepts.