crab0.4.0
crab/docs/How crab works

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:

  1. Build a request: the system prompt, the tool definitions, and the active history.
  2. Stream the response from the provider, forwarding text deltas to the renderer as they arrive.
  3. Record the assistant message, including any tool calls.
  4. Execute each tool call in order, subject to the permission gate, recording one observation per call.
  5. Append the tool results and repeat if the model asked for more work.
  6. Close the turn with a turn_end observation 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:

  1. 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.
  2. 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.

esc