Guide 3

The walkthrough

This guide takes you from a fresh clone to a pull request, then shows you the three things you will want to change first. It assumes you have never used iii.

Read what ghola does not do alongside it. Every section here tells you what works, and that page tells you where the edges are.

The parts, before you install anything

Three things, and confusing them is the main way people get lost.

iii is the framework. An engine plus a set of workers. A worker is a running service that registers functions on a bus, and any worker can call any other worker's function. iii ships about 77 of them. ghola installs 31.

The harness is a worker. harness is iii's agent loop: it takes a message, assembles context, calls a model, runs the functions the model asks for, and repeats. ghola does not have a turn loop. It starts turns on this one.

ghola is a starter kit. It picks which workers to run, on which ports, and wires them together. Everything in this repository is yours to edit once you clone it.

Two of those workers are ghola's own, in workers/. The ladder is a worker rather than a library, because the rung is the one idea here worth having without the rest. It has an upstream you can swap in. Neither worker registers a tool any phase is granted: they serve the factory, the callbacks and you.

flowchart LR
    you(["you"]) --> g

    subgraph kit["ghola: conventions, plus two workers of its own"]
        g["the stage graph
the briefs"] l["ghola-ladder"] a["ghola-audit"] end subgraph iii["iii: the framework, about 77 functions"] h["harness worker
the turn loop"] w["worktree worker"] gh["github worker"] end g --> h --> model(["a model"]) g --> w --> git(["git"]) g --> gh --> pr(["a pull request"]) g --> l --> refusal(["a refusal, in the rule's own words"]) g --> a --> record(["the append-only record"]) classDef kitn fill:#3A2112,stroke:#E7C982,color:#E7C982 classDef own fill:#3A2112,stroke:#A4470F,color:#E8A33D classDef iiin fill:#23150C,stroke:#6E2E13,color:#E7C982 classDef out fill:#1A100A,stroke:#A4470F,color:#E8A33D class g kitn class l,a own class h,w,gh iiin class you,model,git,pr,refusal,record out
ghola starts turns on somebody else's loop, and writes only the two workers that carry its own ideas

Install

You need iii, uv, git, gh and Python 3.11 or newer.

curl -fsSL https://install.iii.dev/iii/main/install.sh | sh
git clone https://github.com/tacoda/ghola.git && cd ghola
make setup

make setup runs make doctor, creates the virtual environment, installs the Python packages, and writes .env from the example. Read what it prints.

Put your key in .env:

ANTHROPIC_API_KEY=sk-ant-...
The failure this prevents

The engine reads this file, not the workers. The provider workers take their credentials from the engine's own environment. Start the engine without the key and the router serves no models. Every turn then fails at router::provider::resolve, and nothing says why. make up sources .env for that reason.

Start it

make up

This starts one compose project and waits. Compose brings up the 30 containers in worker-compose.yaml, and the engine injects its own five. start_after: keeps the record ahead of the policy worker, the factory and the ladder, so the recorders have somewhere to write. The wait ends when every container has registered, or when one has failed. Expect about a minute the first time.

make status
engine   : up
policy   : up
ladder   : up     (bundled)
factory  : up
audit    : up     (bundled)
port 3131: listening      the HTTP surface
port 3133: listening      the iii console
port 3132: listening      the stream server
port 49154: listening     the worker manager

Open http://127.0.0.1:3133. That is the iii console. Best tool here: every function, every trigger, queue depth, and a waterfall for each turn.

The iii console: chat on the left, and a live trace panel on the right listing one session per phase, each with its span count and duration
the iii console, mid-job

The trace panel is the half to watch. A job appears there as one session per phase, so ..._plan and ..._run are separate rows. The span count beside each is every tool call that phase made.

These ports are not iii's stock ones, on purpose: a ghola engine has to run beside another iii project's engine.

To stop: make down. It waits for the ports to free. Reporting one port free while another is still held is the same half-truth as reporting one port down.

Your first turn

make turn PHASE=plan PROMPT="What does this repository do?"

Add WORKSPACE=../some-repo to point it at other code. Without it, the turn works on ghola itself.

You will see the settings, then the model's answer and its cost:

phase     plan
model     claude-opus-5  thinking=high max_turns=50
workspace /Users/you/ghola
rung 1    11 function(s) granted

Read the rung 1 line. It counts the functions this phase may call. A function absent from that list does not exist for this turn, because the harness refuses it before ghola sees it. That is the first rung of the ladder, and the framework enforces it rather than a rule ghola checks. I lean on this more than on any rule I have written: a check handed no editor needs no predicate telling it not to edit.

A turn edits the workspace as it is. make turn mints no worktree, so a run phase writes to the files you are looking at. A job does cut a worktree, which is the first job below.

If nothing comes back, ask the harness directly:

make call FN=approval::list-pending    # a held call is the usual answer
make call FN=harness::status JSON='{"session_id":"s_<id>_plan"}'

If a turn hangs, approval-gate is holding a call. ghola ships config/approval-gate.yaml with default_mode: full so the ladder does the refusing, but a session created before that change keeps its own mode. Check what it is holding, then release it:

make call FN=approval::resolve JSON='{"session_id":"...","function_call_id":"...","decision":"allow"}'

See what is configured

make config
root     : /Users/you/ghola
settings : /Users/you/ghola/settings/phases.yaml  (absent, using built-ins)

plan
  functions      11 allowed: engine::functions::list, …   [built-in]
  max_turns      50                                       [built-in]
  model          claude-opus-5                            [built-in]
  thinking_level high                                     [built-in]
make config, printing every phase with its functions, max turns, model and thinking level, each value tagged built-in
make config, on a fresh clone

That block is trimmed. The picture is the whole thing on a fresh clone: six phases, and [built-in] against every value because settings/ is still empty.

Every value carries where it came from. Configuration in ghola is optional, which makes this command load-bearing rather than convenient. Without it, a default is a magic number, and a settings/phases.yaml with a YAML syntax error looks exactly like agreeing with the built-ins. make config tells the two apart.

Your first job

The section above ran one turn. A job is the whole pipeline: plan, run, prove, review, commit, and a request for somebody to review.

stateDiagram-v2
    direction TB
    [*] --> plan : make submit
    plan --> run
    run --> prove
    prove --> review
    review --> commit
    commit --> commit : the hook refused, so revise
    commit --> request : the hook accepted
    request --> [*] : you merged, so landed
    request --> [*] : you set status closed
    request --> run : you wrote a comment, so rework

    note right of commit
        every transition is a durable
        queue message, so a crash
        resumes rather than restarts
    end note
    note right of request
        nothing merges itself,
        in any configuration
    end note
    
the stage graph, and the three answers a request can get

Name a repository

ghola works on a repository you name in repos.local.toml. Git ignores that file. It beats the tracked repos.toml, which holds the examples.

The shortest path uses a scratch repository and no forge at all. A forge is whoever hosts your code and receives the pull request: GitHub, GitLab, Gitea. The local forge is none of them.

[repos."/Users/you/code/scratch"]
forge = "local"
base  = "main"

That entry needs no account, no token, and no slug. Check it:

make repos

Submit a spec

make submit SPEC=specs/document-the-ports.md REPO=/Users/you/code/scratch
make jobs

Watch it in the console at http://127.0.0.1:3133. Each stage runs a turn or an action. Every transition travels as a durable queue message, so a crash between stages resumes rather than restarts.

make pipeline prints the stage graph as it will actually run, plus anything wrong with it. Read it before you submit. A broken stage found two turns in has already cost you a worktree and a plan.

What comes out

The local forge writes the request into .ghola/requests/ in your repository. It carries the spec, the plan, what the run turn built, what the proof ran, and the review's verdict. Each phase appended its own section as the job went, which is why the file reads as an account rather than as a summary.

Then ghola stops. Nothing merges itself, in any configuration.

You have three answers:

  • Merge the branch. ghola marks the job landed and releases the worktree.
  • Set status: closed in the request file. The job closes.
  • Write a comment under the marker at the bottom. ghola reworks the branch against your words and pushes a second commit.

The comment is the interesting one. It becomes the brief for the next turn, and it replaces the spec rather than joining it. Re-stating the original alongside a specific complaint is how a turn solves the wrong one.

Moving to GitHub

Change the entry and add a slug:

[repos."/Users/you/code/real-repo"]
slug = "you/real-repo"
base = "main"

forge = "github" is the default, so leave it out. Then run make doctor. It asks the github worker which account it is, because your shell and that worker routinely differ. That mismatch surfaces at pr create, after a job has already paid for a worktree, a plan, a run and two checks.

Everything above works the same. The request becomes a pull request. A comment on it becomes the same rework.

Change the three things you will want to change

A phase: which model, how long, what tools

Create settings/phases.yaml. Everything in it overrides a built-in.

phases:
  review:
    thinking_level: high
    max_turns: 30

Run make config PHASE=review. The two keys you set now read [settings], and the rest still read [built-in]. The file merges one level down, so setting one key keeps the rest of the phase.

The exception

functions is replaced, not merged. A phase that lists its own functions gets those and not those plus the defaults. This is deliberate. Rung 1 read as an accident of merge order is how a check ends up holding an editor.

To add a phase that does not exist, name it:

phases:
  threat-model:
    model: claude-opus-5
    functions:
      allow:
        - engine::functions::info
        - coder::read-file
        - coder::search

Then make turn PHASE=threat-model PROMPT="...".

The tools are not ghola's

Look at the function names above. coder::read-file and shell::exec come from iii's shell worker. github::pr::create comes from the github worker.

ghola registers no tools. To find what is available:

make call FN=engine::functions::list       # everything on the bus
make schema FN=coder::read-file            # one function's contract

Grant any of them to a phase by name. This is why extension does not need ghola's permission: a function id is a function id, wherever it came from.

A script: when configuration is not enough

Configuration handles values. A judgment needs code, and code goes in a named directory where it is found by filename.

DirectoryBecomesArrives in
predicates/a rule's checkM3
actions/a stage's actionM4
guards/a stage's conditionM4

A predicate is one function with no ghola imports, so you can run it directly:

# predicates/no_secrets.py
import re

PATTERN = re.compile(r"(sk-ant-|BEGIN PRIVATE KEY)")


def check(path: str, content: str, context: dict) -> list[dict]:
    """Return one finding per offending line, or an empty list."""
    return [
        {"line": n, "why": "a credential in source"}
        for n, line in enumerate(content.splitlines(), 1)
        if PATTERN.search(line)
    ]

There is no registration step and no import to add. If you would rather write it in another language, register it as a worker function and name its function id instead.

The ladder

This is the idea ghola adds to iii, and the reason the rest exists. It arrives at M3. Read it now, because it explains why the sections above are shaped this way.

There are two ladders, and you need both. A constraint withholds something and climbs away from the agent's reach. A capability grants something and climbs toward wider reach. They meet at the grant, which is why one worker serves both.

A constraint has a rung: the mechanism that carries it.

RungWhat carries itCan the agent get past it?
0prose in a rules fileyes, by not reading it
1the phase was never granted the functionno. There is nothing to refuse
2the repository's own hookyes. It can delete the hook
3a callback in front of every callno. It cannot reach the callback
4the delivery gate, over the finished diffno. It runs after the turn
5CI, on the pull requestno. It runs outside the machine

Each rung puts the rule further out of reach of the thing it constrains. The rung is one line of a rule's frontmatter:

---
id: no-secrets
description: A credential never reaches a commit
why: A leaked key costs a rotation and an incident review.
rung: [3, 4]
predicate: predicates/no_secrets.py
---

rung: [3, 4] is two boundaries, not two strictnesses. A callback at rung 3 sees function calls, and a shell command is one call whose contents it does not read. The delivery gate at rung 4 sees the finished diff. Neither sees what the other sees, so a rule that matters names both.

The ladder guide is the whole of it, including the second ladder that capability climbs.

Let it read its own record

A factory that never learns anything is a factory you have to keep teaching. The improve lane reads what already happened and asks one turn what would have prevented it.

make improve REPO=../some-repo

The evidence is the audit log and the job records, so it is what happened rather than what anybody remembers:

  • a refusal, and the rung that caught it
  • a revision the commit gate forced
  • a question the spec did not answer
  • a check that came back concerns
  • a rule that never fired

Trouble is read broadly. A job that reached a merged pull request still counts if it cost a revision on the way. A lane that looked only at outright failures would miss most of what is worth fixing.

If nothing cost anything, no turn runs at all. That is the lane working.

flowchart LR
    subgraph ev["what happened, not what anybody remembers"]
        a["the audit log"]
        j["the job records"]
    end

    a --> t
    j --> t
    t{"did anything
cost something?"} t -- "no" --> quiet["no turn runs.
Zero proposals is an answer"] t -- "yes" --> turn["one improve turn"] turn --> ch["charter lane
CLAUDE.md, rules, hooks"] turn --> ha["harness lane
prompts, policy, budgets"] turn --> fa["factory lane
stages, gates, ordering"] ch --> spec["make accept writes specs/<title>.md"] ha --> spec fa --> spec spec --> sub["you submit it, like any other work"] classDef ev fill:#1A100A,stroke:#6E2E13,color:#E8A33D classDef gate fill:#3A2112,stroke:#E7C982,color:#E7C982 classDef lane fill:#23150C,stroke:#3B2515,color:#E7C982 classDef quiet fill:#0F0A06,stroke:#3B2515,color:#AB9070 class a,j ev class t gate class ch,ha,fa lane class quiet quiet
nothing is applied. Accepting writes a spec and stops
make audit: 43 entries in one file, reported INTACT, then counts by kind and by actor
make audit

make audit is that evidence from the outside: how many entries there are, whether the chain still verifies, and which actors wrote them. Run it before make improve to see how much there is to read.

Where a proposal can go

lanewhat it is abouthow often to expect one
charterthe target repo's CLAUDE.md, rules, hooks, skillsconstantly
harnessprompts, tool policy, phases, budgetsrarer, usually an edge case
factorystages, gates, guards, orderingrarely. Process should be boring

Proposals should thin out with distance from your own code. Most of what goes wrong is a thing the repository wanted and never said out loud. So a run that proposes three factory changes and no charter ones is usually telling you the lane was picked wrongly.

Reading and accepting

make proposals                # every run, newest first
make proposals RUN=abc123     # one run, whole, with the evidence it was given
make accept RUN=abc123 N=0    # the first proposal becomes a spec

Nothing is applied. Accepting writes specs/<title>.md and stops, and you submit it like any other work:

make submit SPEC=specs/give-commits-md-a-scope.md REPO=../some-repo

The exception is promote or demote, which is one number in a rule's file and goes to ladder::move. That changes a file and commits nothing, so it still reaches you as a diff. The rule behind all of this is narrow and load-bearing: the lane that proposes changes to your charter may not edit your charter. Otherwise it would be the one thing here that never passed through a pull request.

What it will not do

  • Propose anything it cannot trace to a job or a signal. Those are dropped, and the run records what it dropped and why, so a quiet run is distinguishable from a run that found nothing.
  • Fill a quiet week. Zero proposals is an answer.
  • Argue from a silence that proves nothing. A rule carried at rung 0 refuses nothing by construction, so it can never appear to have fired however well it is working. The lane reports it as unobservable, and explicitly not as evidence for removing it.

Where to go next

  • make help lists every target.
  • PLAN.md is the phased plan: what is built, what is next, and what each milestone must prove before it counts as done.
  • The iii console at http://127.0.0.1:3133 shows the machinery moving.
  • iii.dev/docs documents the framework underneath.

Troubleshooting

What you seeWhat it is
every turn fails at router::provider::resolvethe engine started without your key in scope. make down && make up
registration token mismatcha stale provider registration. iii trigger compose::restart container=provider-anthropic
make turn never returnsask harness::status for the session. A turn can fail while a listener waits
a port is takenanother iii project's engine. make status, and note ghola is off the stock ports
a rule seems not to firecheck the rung. Rung 0 enforces nothing by design
a turn sits at awaiting_functions foreverapproval-gate is holding a call. iii trigger approval::list-pending, then approval::resolve, or set the session to full
the held call names a path outside the workspacethe turn reached past its filesystem scope and the approval hook parked it. A second root is harness::filesystem::grant, and the improve lane asks for one before it starts
registration token mismatch after restarting one workerthe router and the provider disagree. Restart the whole engine (make down && make up), not one worker
make config says a file is absent that you wrotecheck the path. settings/, not config/, which belongs to iii