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.
cargo
gotchas are in the install guide.
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
run 0 · 63 spans ├── ✓ llm openai/deepseek-chat · 5663ms · ↑254 ↓257.7k (cr0 cw257.7k) ├── ✓ tool read · 4ms │ read {"path":"crates/crab-cli/src/metrics.rs"} ├── ✓ tool grep · 11ms │ grep {"pattern":"fn report"} ├── ✓ llm openai/deepseek-chat · 2896ms · ↑275 ↓258.8k (cr257.9k cw921) ├── ✓ tool edit · 12ms │ edit {"path":"crates/crab-cli/src/metrics.rs","edits":[…]} ├── ✓ tool bash · 9410ms │ bash {"command":"cargo test -p crab-cli metrics"} └── ✓ llm openai/deepseek-chat · 3482ms · ↑323 ↓260.4k (cr259.0k cw1.3k)
crab metrics · last 7 day(s) BY DAY 2026-10-22 turns 2 ↑3.6k ↓309.2k cr308.6k cw622 cost $0.0064 tool-err 0% p50 5481ms p90 5481ms 2026-10-21 turns 41 ↑38.1k ↓4.2M cr4.1M cw96.2k cost $0.0917 tool-err 2% p50 2210ms p90 8940ms 2026-10-20 turns 63 ↑51.4k ↓6.8M cr6.7M cw141.9k cost $0.1402 tool-err 1% p50 1980ms p90 7214ms BY MODEL deepseek-chat turns 104 ↑92.1k ↓11.2M cost $0.2318 kimi-k3 turns 2 ↑1.0k ↓18.4k cost $0.0065 local-model turns 3 ↑2.1k ↓41.0k cost −
$ crab --print "which module renders the metrics table?" ◆ crates/crab-cli/src/metrics.rs — `report()`. It reads the `turns` table through two queries: one grouped by day, one by model. The text form prints directly; `--json` serialises the same rows. $ crab --print "add --json" && cargo test -p crab-cli $ echo $? 0
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_fetchtool — including applying its own code-review feedback. greprespecting.gitignore.- The MCP client: a
Tool::namerefactor, config sections, the stdio client, startup wiring. - Its own README and
install.sh.
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
moddeclaration — 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
reedlinefor 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 /
reedlinelayout 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.
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.
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.
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.
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.
kill_on_drop, output truncated head + tail.**/*.rs..gitignore, skips hidden files.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 mockis 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: 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 diffand hand-editing. The metrics ledger is SQLite; the conversation is not. - an OS sandbox
sandbox-execis deprecated on macOS; landlock/seccomp means a second platform-specific implementation for a half-real sandbox. A container is real. - local tokenisation
tiktoken-rsships BPE tables into the binary and is wrong for DeepSeek, Kimi and Claude anyway. crab reports the provider's ownusage, 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.
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!.
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.
stable crab-cli
clap, crossterm, reedline, rendering, the permission prompt UI, and the
trace / metrics subcommands. The binary is named
crab.
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.