crab is a loop around one request shape. Understand the request and the log, and the rest of the tool is detail.
A turn
A run is a sequence of turns. Each turn does exactly this:
- Build a request: the system prompt, the tool definitions, and the active history.
- Stream the response from the provider, forwarding text deltas to the renderer as they arrive.
- Record the assistant message, including any tool calls.
- Execute each tool call in order, subject to the permission gate, recording one observation per call.
- Append the tool results and repeat if the model asked for more work.
- Close the turn with a
turn_endobservation carrying the stop reason and counts.
Nothing is hidden in a side channel. If a turn did something, there is a line for it in the session file, and a row for it in the ledger.
Concurrency is one task: the agent runs on its own tokio task, commands go in over an
mpsc, events come out over a broadcast, and abort is a
CancellationToken.
What goes into a request
- Base system prompt
- The role, the tool list, and the editing rules. Deliberately short — it is paid for on every turn.
- Tool descriptions
- Each one lives in the same file as its implementation, so the prompt cannot drift from the behaviour.
- AGENTS.md
- Appended when present in the working directory, labelled as project instructions. This is where house rules belong.
- --system
- Extra text appended after that, for one run.
- Skills
- Name, description and path only. The body stays on disk until a task matches.
- History
- The message log, with compaction applied by replay.
Streaming, and why the parser is fussy
Responses arrive as text deltas and tool-use blocks. Tool arguments arrive as bare-string JSON
fragments, and providers disagree about the details: some omit the array index, some send
finish_reason in the same frame as tool_calls, some never emit a stop before
[DONE].
crab therefore uses a tolerant accumulator: slots keyed by index, falling back to
appending to the last slot when the index is missing. Arguments are parsed only once complete — and if
parsing fails, the raw string is fed back to the model to retry rather than being silently dropped.
Retry policy. 429 and 5xx are retried with exponential backoff, but only before any content has streamed — retrying mid-stream would duplicate text. A stream that breaks after a partial tool call is a visible failure with partial content persisted, not a silent retry.
Usage is captured from the provider's own accounting. One known trap, found the hard way: DeepSeek
sends usage in the same SSE chunk as finish_reason, so breaking the stream on
StreamEnd before reading usage loses it.
Context and compaction
Every request re-sends the active history, so cost grows with conversation length unless the
provider caches. When the last request's prompt crosses auto_compact_threshold (default
0.8) of the context window — or when you type /compact — crab condenses
before the next request, in two stages:
- Shed (deterministic): old tool results become short placeholders, and long text blocks are truncated. The model can re-run a tool to see the content again.
- Summarize (only if shedding was not enough): older turns are replaced by an LLM-written summary.
The session log is never rewritten. Compaction is recorded as an event, and the model's view
is rebuilt by replaying it, so --continue sees exactly what the model saw. Set
auto_compact_threshold = 0 to disable the automatic trigger.
Where the numbers come from
crab never tokenises your prompt itself. It reports what the provider said it used:
- prompt
- Total input tokens, including the cached portion.
- completion
- Output tokens.
- cache_read
- Prompt tokens the provider served from cache. Billed at the cache-read rate.
- cache_write
- Prompt tokens written into the cache. Billed at the cache-write rate, which some providers fold into the miss price.
The billed input is therefore prompt − cache_read. That one subtraction is the whole
reason the pricing page exists, and the most common place a hand-rolled cost
estimate goes wrong.
A rough chars/4 estimate is used for one thing only: the "about to run out of window"
warning, before a request happens. It never reaches the report.
When things fail
A failed tool call does not end the run. The error text goes back to the model as the tool result,
the observation is marked with a non-Ok outcome, and tool_errors goes up in
the ledger — which is why tool-err is a column in crab metrics and not a
footnote. A failed provider call ends the turn and is recorded with the error as its outcome.