crab0.4.0

Rust coding agent · v0.4.0 · Apache-2.0

A coding agent that built itself.

crab is a terminal agent in Rust, inspired by pi. The acceptance criterion was not a benchmark or a star count — it was one commit whose diff came entirely from crab. That landed on day one of dogfooding. 21 commits in its own history followed the same way, each reviewed by a human before it merged.

Install — macOS & Linux
$curl -fsSL https://crabagent.ai/install.sh | sh
Needs a Rust toolchain. Prebuilt binaries and the two cargo gotchas are in the install guide.
~12 MiB binary 7 tools MCP client OpenAI-compatible JSONL sessions Apache-2.0
21commits whose diff crab wrote
340tests, 3 timing-sensitive ignored
16,155lines of Rust
< 1scold start

Counted in the repository at v0.4.0. The first number is the one that matters, and it keeps growing — every entry is dated in docs/dogfood-log.md.

  crab · deepseek/deepseek-chat · working · 12.4k/128k · ~/src/crab-agent

crab> add a --json flag to `crab metrics` and cover it with a test

◆ Let me read the renderer before changing it.

  ⏺ read   crates/crab-cli/src/metrics.rs          1.9 KB
  ⏺ grep   "fn report"                            2 hits
  ⚠ edit   crates/crab-cli/src/metrics.rs
     allow? [y]es/[n]o/[a]ll: y
  ⏺ edit   crates/crab-cli/src/metrics.rs          +38 −4
  ⏺ bash   cargo test -p crab-cli metrics          ✓ 4 passed

◆ Added `--json` to `report()` and four tests. The text report is
  unchanged; JSON emits `daily[]` and `models[]`. 

 ↑3.6k ↓309k  R308.6k W622  CH 99%  $0.0064  99.8% ctx  yolo off
trace and metrics show the real report shapes. The first panel is a reconstruction of the TUI, not a screen capture.

The self-hosting arc

It was given a task in its own repo.

The design doc set a criterion that could not be gamed: a real commit, in the history, whose diff crab produced. Not a demo, not a benchmark.

Commit f3fec8e is sixteen lines adding Session::len and a unit test. Deliberately small — the criterion was about provenance, not size. After that the scope grew:

  • The entire TUI layer: semantic colour theme, ANSI-aware width helpers, the status line, structured tool blocks.
  • Token-usage capture through the SSE pipeline.
  • The web_fetch tool — including applying its own code-review feedback.
  • grep respecting .gitignore.
  • The MCP client: a Tool::name refactor, config sections, the stdio client, startup wiring.
  • Its own README and install.sh.
git show --stat f3fec8e
commit f3fec8e10581c7c3c6288753c00667f52fff5d77
Author: jinpeng <jinpeng.dong@gmail.com>
Date:   Wed Oct 14 11:35:03 2026 +0800

    dogfood

 crates/crab-core/src/session.rs | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

The diff is crab's. The review and the commit are the human's. That distinction is the whole claim.

What it bought the design: the properties that make a diff reviewable are the same ones that make an agent usable. Literal edits, a freshness gate, and a replayable log were things crab needed in order to work on itself — not features built for a demo.

Receipts

Where it was good, and where it wasn't.

From the dogfood log. A project page that lists only wins is not telling you how to use the tool.

It caught things on its own

  • Noticed a task spec forbade touching other files while its own test could not run without a mod declaration — verified out-of-tree and reported the conflict instead of claiming a pass.
  • Found a flaky test was pre-existing by stashing its changes and re-running the original.
  • Discovered the agent emitted no "waiting for confirmation" event, so it added a shared flag to coordinate the renderer with the prompt.
  • Wrote a hand-rolled JSON-RPC MCP server inside a test, so the client could be exercised without a real subprocess.
  • Declined the one genuinely hard problem — a persistent status line fighting reedline for the bottom row — explored it, and said so rather than writing broken code.

Where a human stepped in

  • rmcp's handshake had no timeout, so a non-MCP subprocess hung forever. Fixed at design level with a 10 s handshake timeout.
  • A timing-sensitive self-spawn round-trip test needed #[ignore] and serialisation.
  • The status-line / reedline layout conflict was solved by hand.

The pattern: strong on well-specified, testable units; honest about the fuzzy ones. The human's job moves from writing to specifying crisply and reviewing.

Why it works

Three decisions the rest depends on.

Each is in the design record with the alternatives that were rejected, and why.

Q8 · sessions

Append-only JSONL, no SQLite

A session is a file you can tail -f, git diff or hand-edit. SQLite would buy query power crab does not need and cost the debuggability it does.

Q14 · edits

Literal match, never fuzzy

Zero matches or more than one is a loud error. Fuzzy matching swaps a visible failure for a silent wrong edit — and the model reads "ok" and keeps going.

Q9 · permissions

A seatbelt, not a boundary

Reads pass, writes and network ask. The docs say plainly that command-string allowlists are bypassed by bash -c and eval. Real isolation is a container.

Observability

One log, two readers.

The session file is the single source of truth. Model calls, tool calls, token counts and content digests are appended as typed observation records — never sent to the model, never rewritten.

Two subcommands read it back: crab trace for the shape of a run, crab metrics for the money. Compaction is recorded as an event and the model's view is rebuilt by replaying it, so --continue sees exactly what the model saw.

  • Four-way token usage: prompt, completion, cache read, cache write.
  • Costs come from a versioned price table with peak/valley windows.
  • Unpriced models show −, never a fake $0.

How to read both reports →

session-1792661000878881000.jsonl
{"kind":"meta","format_version":2,"created":"1792661000878ms"}
{"kind":"message","id":1,"parent_id":null,
 "message":{"role":"user","content":[
   {"type":"text","text":"add --json to crab metrics"}]}}
{"kind":"obs","id":2,"event":{"run_id":0,"ts":1792661135542,
 "obs":"llm_call","provider":"openai","model":"deepseek-chat",
 "duration_ms":1333,"outcome":"Ok",
 "usage":{"prompt":9618,"completion":131,
          "cache_read":384,"cache_write":9234}}}
{"kind":"message","id":3,"parent_id":1,
 "message":{"role":"assistant","content":[
   {"type":"tool_use","id":"call_00_…","name":"read",
    "input":{"path":"crates/crab-cli/src/metrics.rs"}}]}}
{"kind":"obs","id":4,"event":{"run_id":0,"obs":"tool_call",
 "tool":"read","call_seq":0,"args_digest":"aa398eb4",
 "result_digest":"f9b8e67e","bytes_in":40,"bytes_out":1943,
 "duration_ms":4,"outcome":"Ok"}}
{"kind":"obs","id":5,"event":{"run_id":0,"obs":"turn_end",
 "stop_reason":"end_turn","interrupted":false,
 "llm_calls":3,"tool_calls":7}}

Tools

Seven tools, described next to the code that runs them.

Each tool's description lives in the same file as its implementation, so the prompt cannot drift away from the behaviour.

read
UTF-8 text, truncated past 100 KB. Marks the file as read for the freshness gate.
write
Create or overwrite. Overwriting needs a prior read and an unchanged mtime. Atomic.
edit
Exact-match edits, each anchor unique, applied all-or-nothing against the original.
bash
120 s timeout, kill_on_drop, output truncated head + tail.
glob
Find files by pattern, e.g. **/*.rs.
grep
Regex over contents; respects .gitignore, skips hidden files.
web_fetch
Readable main-article text, with nav, ads and scripts stripped.
<server>__<tool>
Every MCP tool, namespaced so two servers can both expose search.

The freshness gate is the interesting part. edit and write refuse a file you have not read this session, and re-check its mtime. That is what stops the agent from silently clobbering the edit you just made in your own editor.

Providers

One wire protocol, profiles, per-field overrides.

crab talks the OpenAI chat-completions API. A provider is a profile in ~/.crab/config.toml. With no config at all it uses api.openai.com and gpt-4o-mini, so export CRAB_API_KEY=… is a working setup.

  • Environment beats the profile, field by field: CRAB_BASE_URL, CRAB_MODEL, CRAB_CONTEXT_WINDOW.
  • --provider mock is a built-in stub — the fastest way to debug the loop without spending tokens.
  • /model <name> switches mid-session; the transcript is provider-agnostic.
  • Local endpoints may omit the key; anything else without one is an error.
# ~/.crab/config.toml
default_provider = "deepseek"

[providers.deepseek]
base_url       = "https://api.deepseek.com/v1"
model          = "deepseek-chat"
api_key        = "sk-…"        # optional
context_window = 128000

[providers.local]
base_url       = "http://localhost:8080/v1"
model          = "qwen"

auto_compact_threshold = 0.8   # 0 disables

Then: crab --provider local, or /model deepseek inside a session.

MCP & skills

Extend it without an ABI.

There is no plugin format to learn. External tools arrive over the Model Context Protocol; capability packages arrive as Agent Skills.

An MCP server is a [mcp.<name>] block. Its tools are registered as <server>__<tool>, and several tools from one server share a single child process. Startup is best-effort: a server that fails to spawn warns and is skipped, never blocking crab from starting.

Skills are directories with a SKILL.md, discovered in .agents/skills/ and ~/.agents/skills/. Only name, description and path go into the prompt — the body stays on disk until the task matches.

MCP configuration →

# MCP: extra tools, no rebuild
[mcp.brave]
command = "npx"
args    = ["-y", "@modelcontextprotocol/server-brave-search"]
enabled = true
env     = { BRAVE_API_KEY = "…" }

# behind a corporate proxy, a Node server also needs:
#   NODE_OPTIONS = "--use-env-proxy"

# Skills: advertised by name, loaded on demand
.agents/skills/my-skill/SKILL.md
~/.agents/skills/my-skill/SKILL.md

The model sees brave__brave_web_search. Use /skill:<name> to inject one explicitly.

Scope

What we didn't build.

Not a backlog — each is a decision with a reason, written down so it stops being re-argued.

  • a JS or WASM plugin runtimeDeno would drag the binary past 50 MB; Wasmtime plugs into neither the TUI nor the event stream, and nobody writes WASM for a new agent. The honest in-process extension story in Rust is fork and recompile — 12 MiB and seconds of incremental build makes that cheap.
  • SQLite sessionsQuery power crab does not need, traded for losing tail -f, git diff and hand-editing. The metrics ledger is SQLite; the conversation is not.
  • an OS sandboxsandbox-exec is deprecated on macOS; landlock/seccomp means a second platform-specific implementation for a half-real sandbox. A container is real.
  • local tokenisationtiktoken-rs ships BPE tables into the binary and is wrong for DeepSeek, Kimi and Claude anyway. crab reports the provider's own usage, which is authoritative and free.
  • an alt-screen TUIFull-screen takes over scrolling and selection, so you end up writing scrollback, mouse selection and search yourself. Streaming output upgrades incrementally and stays pipeable.
  • sub-agents, themes, fork/branchDeliberately future work. The branching fields (parent_id) already exist in the log, so the format will not need migrating when the reader arrives.
  • OAuth subscription loginLegal and token-refresh cost, not a technical one. API keys and environment variables only.
  • telemetryNo reporting endpoint, no account. The ledger is a local file.

The full record, with the reasoning for each →

Projects

Two crates. One cut.

The workspace is split exactly once, and the boundary is enforced: crab-core has no terminal dependency and no println!.

workspace

stable crab-core

The agent loop, session log, provider client, tools, prompt assembly, compaction, MCP and skills. No terminals, no printing — so it can be embedded or tested headlessly.

crates/crab-core
workspace

stable crab-cli

clap, crossterm, reedline, rendering, the permission prompt UI, and the trace / metrics subcommands. The binary is named crab.

crates/crab-cli

Dependency graph, and the projects crab borrows from →

Start

Build it, point it at a folder, read the log.

One Rust toolchain and one API key. You can have a session you understand in about five minutes — including the trace.

Prebuilt binaries for macOS arm64 and Linux x86_64 / arm64 on Releases.

esc