A Rust CLI that is one stage in a pipeline: prompt and context in on argv/stdin, the answer out on stdout, diagnostics on stderr, a verdict in the exit code. It speaks to any OpenAI-compatible endpoint — built against llama.cpp's server, since verified against a remote gateway and an MLX one too.
cargo build --release
./target/release/clank [OPTIONS] [PROMPT...]Recorded against a live server by scripts/record-demo.sh: the four read-only
observers, the pipe as context, --each mapping one prompt over three items, a
schema-constrained answer through jq, and what failure looks like — a legible
reason and exit 1.
- CHEATSHEET.md — flags, one-liners, integrations
- docs/use-cases.md — the job families, each with its gate and its price
- PROTOCOL.md — the contract: invariants, request sequence, context doctrine, event set, exit codes
- docs/macbook-omlx-local-inference.md — it running against local models on a 16 GB Mac: which model per job, the programming workflow as one-liners, hallucination probes, what to take from Jev to make it less hallucinatory, what does not work
Not on crates.io — the crate named clank there is an unrelated project — and no
prebuilt binaries. Two binaries — clank and its decision-stage sibling clank-jev —
six direct dependencies, and nothing system-provided
beyond a C compiler (ring, for TLS; no OpenSSL to find):
cargo install --locked --git https://github.com/makefunstuff/clank # -> ~/.cargo/bin/clankThat directory is on PATH if you have installed anything with cargo before; if
clank: command not found is your first result, it is not. --locked holds the
dependency graph to the committed Cargo.lock; --rev <sha> pins clank itself,
which is what you want when wiring it into something else.
From a checkout instead, if you would rather read it first — src/ is 2.7k lines:
git clone https://github.com/makefunstuff/clank && cd clank
cargo build --release # -> ./target/release/clank
cargo test # no model and no network: a stub SSE server and a stub
# JSON one, driven by the real binariesAt run time it needs an OpenAI-compatible endpoint that does tool calling.
llama.cpp's llama-server is what it was built against, and the only piece here
that wants a GPU. Point it at one and check the round trip:
export CLANK_BASE_URL=http://127.0.0.1:8080/v1
export CLANK_MODEL=$(curl -s "$CLANK_BASE_URL/models" | jq -r '.data[0].id')
clank -m 'reply with exactly: pong' # -> pong, exit 0The built-in defaults (http://127.0.0.1:40583/v1,
qwen3.8-27b-gsq-rco-iq3xxs) are one machine's. Those two variables plus the flags
in Configuration are the whole configuration — no config file, no
state on disk — and a model the server does not serve is the usual first failure. It
is not silent: the server's reason goes to stderr and the exit code is 1.
Container instead of a toolchain: In a container.
A persistent agent harness is a large program whose tool surface you trust by default, because you will not read it. When that surface can write files and run commands with your permissions, what you are trusting is the whole machine.
The surface here is stdin, stdout, stderr and an exit code. No daemon, no session
store, no memory you did not hand over. Everything an "agent" does internally is a
pipeline step you can read — rg, git diff, jq, sed, xargs -P.
So the input is visible: the context is exactly what you piped. The cost is visible: one call is one call, with no loop re-sending a growing history. And the write stays yours — clank observes and proposes, a gate decides, you apply. In a container with the tree mounted read-only that boundary belongs to the kernel rather than to clank's promises; it bounds what the model can write, not what it can reach.
What it does not give you: memory across sessions, retrieval you did not construct, or a loop that edits your code while you are away. Those are what a harness is for.
Ask a model about text you already have, and get back something your shell can act on:
rg -n -C3 "userData" src/ | clank --thinking off -m "what does this do?"
git diff | clank --thinking off --max-tokens 400 -m "review this diff, one line per issue"
rg -n "TODO" src/ | clank --each --thinking off -m "one line: actionable now, or not?"
clank --json-schema @schema.json -m "extract the findings" | jq -er .
clank --jsonl -m "summarize" | clank -m "what did you say?"
clank --system @prompts/review-sh.md -c script.sh -m "review the script"- stdout is data. The answer, framed
--eachanswers, or--jsonlevents —run,item,tool_call,tool_result,assistant,error— and nothing else. A--jsonlstream opens with arunevent naming the model, the endpoint and the argv (API keys redacted), so a trace says what produced it. - stderr is diagnostics. Tool breadcrumbs, reasoning under
--show-thinking, errors. Token accounting stays in the server's log, where it already lives. - exit codes.
0ok;1failure — model, server, IO, a truncated answer, an empty answer, or any failed--eachitem;2usage. Truncation and emptiness are failures, not results: a stage that reports a success it cannot back is worse than one that fails. - prompt comes from
-m TEXT, positional text, piped stdin (stdin is the prompt only when no other prompt is given), or one line from a TTY. - context is piped stdin, which becomes a context node when a prompt is also
given: a clank trace renders as a transcript, anything else as text.
-c FILE(repeatable) loads a saved context — JSON tree, text, or a trace.-cnodes come first, then the pipe; a pipe that reaches the process is never dropped. - tools are offered only when nothing was piped — no stdin, no
-c, no--eachitems — because with no evidence to hand over, the only honest answer about your workspace is one that looked at it. Every lookup lands on stderr. When evidence was supplied, that is the evidence and nothing else.--no-toolsforces the blind case, and the prompt then forbids citing what it never saw. - no colour, plain text on both channels.
--quietdrops the breadcrumbs. - SIGPIPE restored (Rust sets SIG_IGN by default), so
clank … | headdies cleanly; output is line-buffered and flushed per delta.
A context file is plain text (one text node) or a JSON tree:
[
{ "text": "raw text node" },
{ "file": "path/relative/to/cwd" },
{ "children": [ { "file": "a.md" }, { "text": "more" } ] }
]File leaves are read at render time and emitted as ─── path ─── plus content. A
-c file is validated strictly, because a file is configuration someone wrote on
purpose; the pipe parses leniently, because it is evidence.
One prompt over every item on stdin, serially, one conversation per item. The items
are the context, so the prompt has to come from -m/positional.
git log --format=%s -3 | clank --each -m "one line: rewrite in the imperative mood"
find src -name '*.rs' -print0 | clank --each -0 -m "one line: what is this path for?"Text mode frames each answer with the item it belongs to, so stdout maps back to
stdin (awk '/^─── item /{…}' splits it):
─── item 1/3 ───
readme: add install section and fix name clash
─── item 2/3 ───
use-cases §9: implement loop and goal in eight lines of shell
─── item 3/3 ───
readme: third shorter and remove essay voice
An item is text, not a file. The item is the context, so rg -l "TODO" src/ | clank --each -m "summarize this file" hands the model a filename and nothing else,
and it answers accordingly — measured on oMLX (2026-09-19): "This file exists but
its purpose is not described in the provided context." To summarize files, let the
shell read them:
for f in src/*.rs; do
clank -c "$f" -m "one line: what is this file responsible for?" </dev/null
echo
done</dev/null because clank reads stdin — without it the first call eats the rest of
the list. Content you already gathered maps fine: rg -n "TODO" src/ | clank --each -m "one line: actionable now, or not?".
--jsonl adds i (1-based) and of to every event and emits one item event
carrying the input before the work starts, so a consumer never has to guess. A
failed item is an error event and a stderr line, and it does not stop the run —
fifty items should not be thrown away because the third one timed out. The exit
code is 1 if any item failed. An empty item list is not a failure.
Everything except the trailing item block is byte-identical from item to item,
which is what lets the server reuse the prompt prefix. There is no parallelism
inside clank: -P belongs to xargs.
The model is the constraint — clank's own share is 1 ms of startup — so the lever is the tokens you pay for:
| rule | measured |
|---|---|
--thinking off for mechanical work |
1.35 s → 0.25 s |
| keep the prompt head stable, vary the tail | 10.4 s → 0.21 s (cached prefix) |
stop reading when you have enough (clank … | head -1) |
7.1 s → 0.6 s |
| one call holding many items beats many small calls | 1.5× on four items |
Per call: ≈0.25 s fixed, ≈0.02 s per output token, prompt tokens at 1.3 ms cold and
0.02 ms cached. docs/research-harness-constraints.md §5.2 has the raw numbers and
§5.3 the comparison against headless pi.
| tool | description |
|---|---|
read_file(path, start_line?, end_line?) |
numbered file content |
list_dir(path) |
directory entries (name, type, size) |
search(pattern, path?, ignore_case?, context_lines?, glob?) |
regex search, file:line: text |
stat(path) |
file/directory metadata |
A tool call is the wrong way to feed a stage: it is a worse rg with an input
nobody can see in the pipeline. The composable form of a lookup is a pipe, and the
composable form of looking twice is a second stage. Reach for --tools when one
lookup inside one stage is genuinely cheaper than a second stage.
search is deliberately weaker than ripgrep: line-based, single-line patterns,
capped at 500 matches and 4 MB per file, skipping binaries, symlinks and .git.
Flags: ignore_case, context_lines (0–10), glob. For anything heavier, compose
with the real tool.
Tool errors go back to the model as data. A bad context file or a server error is a process failure.
A second binary in this crate, for one job: ask typed questions about a state and
get answers a shell can branch on. clank writes prose; clank-jev picks one of
your options and says how sure it is.
route=$(printf '%s' "$task" | clank-jev --ask 'What kind of task is this?' \
--choice code,prose,math --min-prob 0.7) || route=unclear
case "$route" in
code) clank --model local-code -c src/context.rs -m "$task" ;;
*) clank --model local-fast -m "$task" ;;
esacWhy a separate binary rather than a clank flag. clank's contract is one
prompt, one request, one answer, no second wire protocol (invariants 1–3). A
decision stage also stands on its own — a git hook, a Makefile, a cron job — and a
script that wants a decision should not have to carry a chat client to get one.
Providers. Credentials come from the environment, never from argv:
--provider |
endpoint | credential | default model |
|---|---|---|---|
auto (default) |
whichever credential is set | — | — |
typesafe |
api.typesafe.ai/v1/systemone |
TYPESAFE_API_KEY (or JEV_API_KEY, JEV_CLI_API_KEY) |
jev-latest |
openrouter |
openrouter.ai/api/alpha/decisions |
OPENROUTER_API_KEY |
typesafe/jev-1.13 |
kev |
127.0.0.1:8009/v1/systemone (--base-url to move it) |
none | kev-latest |
The kev provider is a local System One server — kev
is a trained Jev-family model (LoRA + pointer readout head on Qwen, one prefill
pass) that speaks the same request and response shapes, so it needs no
credentials and no network:
git clone https://github.com/jaredpalmer/kev && cd kev && uv sync --extra serve
KEV_DTYPE=fp32 uv run --extra serve python -m kev.serve --run jaredpalmer/kev-0.6b --port 8009Measured on the same 18 typed decisions as the table in
docs/decision-readout.md: kev-0.6b 16/18 = 89%,
83 ms median, control 17% (hosted Jev: 94%, 591 ms, $0.000015). It passes the
shuffled-context control, so the accuracy comes from the state. kev-4b is the
checkpoint they recommend and it does not fit here — it serves bf16 only, ~8.5 GB
against 16 GB shared with a resident oMLX model.
A closed-choice reason rides along with the value, decided in the same request
— a judgment, not just a score. fixtures/checks-verification.json asks the two
questions a shadow watchdog asks, each with its own reason set:
printf '%s\n' "USER: run the tests, do not touch the config" \
"TOOL cargo test -> FAILED" "ASSISTANT: all tests pass, config updated" \
| clank-jev --checks fixtures/checks-verification.json --json --min-prob 0.6{"answers":{"verification":{"type":"noul","value":true,"probability":0.98,
"reason":"verification_contradiction","reason_probability":1.0}}, ...}Every question shape is available on the command line, so a script needs no
file: --ask with --choice A,B,C (unordered options), --boolean (yes/no), or
--score low,mid,high (ordered levels). --checks FILE takes the full set, Jev's
own JSON shape.
Gates, and the exit codes a script branches on. --min-prob fails a decision
you asked not to trust; --expect and --expect-min fail one that is not the
value you needed. A question the provider skipped, or an answer carrying no
probability, fails the gate instead of passing by default. --print-reason puts
the closed-choice reason on stdout instead of the value, for a script that routes
on why rather than what.
--min-prob compares against confidence in the decision, not the probability of
"yes". For a yes/no question those differ: a decisive no has probability near 0
and confidence near 1, so a gate on the raw probability would reject the model for
being certain. The JSON carries both — probability is P(true) for noul and the
winning option's share otherwise, confidence is max(p, 1-p) for noul and the
provider's own normalized margin for choice/score when it sends one.
clank and clank-jev are both stages, so they compose in both orders. Each of
these was run; the observed behaviour is quoted.
Route, then generate — the decision picks the model, clank does the work:
route=$(printf '%s' "$task" | clank-jev --ask 'What kind of task is this?' \
--choice code,prose,math --min-prob 0.7) || route=unclear
case "$route" in
code) clank --model local-code -c src/context.rs -m "$task" ;;
*) clank --model local-fast -m "$task" ;;
esacGenerate, then validate — clank writes, jev checks it against a rubric, and a
failed check stops the pipeline. fixtures/checks-commit.json asks whether the
message describes the diff and what shape its subject line has:
msg=$(git show HEAD | clank -q --thinking off -m 'Write the commit message for this diff.')
{ git show --stat HEAD; printf 'MESSAGE:\n%s\n' "$msg"; } \
| clank-jev --checks fixtures/checks-commit.json --min-prob 0.6Observed: describes = true, reason no_conflict, p=0.89 — and the gate still
fired at shape (p=0.500), because README.md: update … is a path prefix rather
than a typed one. That is the gate doing its job on a genuinely ambiguous answer.
Audit a trace after the fact — a --jsonl trace is evidence, so the watchdog
questions can be asked of it afterwards:
clank --jsonl -m 'where is the transcript cap defined? cite file:line' > trace.jsonl
clank-jev --checks fixtures/checks-verification.json --min-prob 0.6 < trace.jsonlObserved: verification = false, reason no_conflict (the citation was real),
and instruction at p=0.23 — no user instruction exists in a trace, so the check
correctly reports it cannot decide, and the gate fails the run rather than
reporting a clean bill of health.
Fan out, act only on confident decisions — the loop is the shell's:
while read -r subject; do
v=$(printf '%s' "$subject" | clank-jev -q --ask 'Could this break an existing caller?' \
--boolean --min-prob 0.7) || { echo "unclear: $subject"; continue; }
[ "$v" = true ] && echo "check: $subject"
done < <(git log --format=%s -8)Observed: 7 of 8 doc-only subjects decided false at confidence ≥ 0.83, and one
came back unclear at 0.68 — the run that found the --min-prob semantics above.
Escalate: local first, hosted only when the local answer is not confident — a credential ladder, the same shape as a model ladder:
ask() { printf '%s' "$1" | clank-jev -q --provider "$2" --ask 'Which team owns this?' \
--choice BILLING,TECHNICAL,ACCOUNT --min-prob "$3"; }
v=$(ask "$state" kev 0.9) || v=$(ask "$state" openrouter 0.5)Observed: the local kev-0.6b decided TECHNICAL at confidence 0.84, below the
0.9 gate, so the hosted Jev was asked and agreed — 83 ms and $0 spent before
reaching the network.
Break a tie between two answers — two models, one judge:
a=$(clank -q --model local-fast -c src/context.rs -m "$task One line.")
b=$(clank -q --model local-code -c src/context.rs -m "$task One line.")
printf 'A: %s\n\nB: %s\n' "$a" "$b" \
| clank-jev --ask 'Which answer names the exact file:line and the correct value?' --choice A,B --min-prob 0.6Observed: it picked B at p=0.75, confidence 0.51 — and the gate fired, because neither answer had the right line number. A tie-break that can say "both of these are wrong" is the reason to use one.
| code | meaning |
|---|---|
0 |
decided, and every gate passed |
1 |
a gate failed — the decision is still printed, because the caller asked not to trust it, not to lose it |
2 |
usage: no question shape, an empty state, a malformed checks file |
3 |
provider, network or credentials |
stdout is the bare value for one question (code, true, 2), a JSON object for
several; diagnostics and every gate failure go to stderr; -q silences them.
Flags override $CLANK_* environment variables, which override built-in defaults:
| flag | env | default |
|---|---|---|
-m / --message / positional |
— | — |
-c / --context |
— | — |
--each |
— | false |
-0 / --null |
— | false |
--tools |
— | on only when nothing was piped |
--no-tools |
— | false |
--thinking |
— | server default |
--show-thinking |
— | false |
--model |
CLANK_MODEL |
qwen3.8-27b-gsq-rco-iq3xxs |
--base-url |
CLANK_BASE_URL |
http://127.0.0.1:40583/v1 |
--api-key |
CLANK_API_KEY |
none |
--timeout |
CLANK_TIMEOUT |
600 s per request |
--max-tokens |
— | 8192 |
--json-schema |
— | none |
--system |
CLANK_SYSTEM |
— |
--list-tools |
— | — |
--max-rounds |
— | 12 tool rounds per prompt (with --tools) |
--jsonl, -q/--quiet, -h/--help |
— | — |
demo.sh is the reference pipeline. Stage 1 writes a script that renders
fixtures/notes.md as HTML; stage 2 critiques it against the fixture and the
shell's own measurements; stage 3 finalizes it under --json-schema; stage 4 has
the model propose a command that verifies the result. The fixture is piped at every
stage, so no stage asks the model to read the filesystem for itself; every stage is
gated, and a non-zero exit, an answer that is not the expected JSON, or a script
that fails bash -n stops the run. One --jsonl trace per stage lands in
local/demo-traces/.
Its first version did print "passed" after producing a broken artifact. That is what the gates are for.
Dockerfile builds a 32 MB image (33,063,772 bytes by docker image inspect)
holding both binaries and nothing else.
docker build -t clank .
git log -1 --stat | docker run --rm -i --network=host \
-v "$PWD":/w:ro -w /w \
-e CLANK_BASE_URL=http://127.0.0.1:4000/v1 \
-e CLANK_MODEL=deepseek/deepseek-v4-flash \
clank --thinking off -m "The context is git log --stat. One line: what changed and the risk it carries."Verified 2026-09-19 with the repo mounted read-only; it answered:
docs/use-cases.mdgained §8 (87 lines) andPROTOCOL.mdone line, … the risk is that the units are unverified in-place — only hand-tested once, where a truncated run left arunevent with noassistant.
With nothing piped, the read-only observers look around inside the container, each lookup on stderr:
> search context_lines=0 glob=* ignore_case=true path=. pattern=exit.?code|EXIT_|exit\(2\)|return 2
< search ok (3019 B)
> list_dir path=.
< list_dir ok (326 B)
and the answer cited src/main.rs:10.
Four details are not optional. -i is what lets the pipe reach it. --network=host
is what makes a model on localhost reachable, and it is also the limit of the
sandbox. :ro is not decoration: mounted read-only, touch /w/pwned returns
Read-only file system. And not alpine — clank is glibc-dynamic, and musl has no
loader for it; ubuntu:24.04 and debian:stable-slim both work.
- Endpoints: qwen 3.8 27b (Qwen3.8-27B GSQ-RCO IQ3_XXS) on
:40583and qwen 3.6 35B-A3B (Qwen3.6-35B-A3B-UD-Q5_K_S) on:37313, both via the OpenAI-compatible endpoint with SSE streaming and tool calling; plus a remote gateway on:4000. On 2026-09-19, oMLX 0.7.0 on:8000serving MLX models on a 16 GB M1 Pro:Qwen3.5-9B-MLX-4bit,MiniCPM5-2B-MLX-8bitandgemma-4-E4B-it-MLX-4bitall answer, stream and expose reasoning; the fourth listed model,Bonsai-2-27B-CRACK-1.75bit-JANG, loads on no request at all — the runtime answers 409 and clank prints the server's reason verbatim, 402 parameter names included. Details and raw output indocs/macbook-omlx-local-inference.md. - A top-level
json_schemais the endpoint's grammar to enforce, not clank's. Against the local endpoints it held: asked to replybetaunder a schema whose only legal value wasalpha, clank printed{"word":"alpha"}. Through the gateway (2026-09-19) it did not: a fenced block came back with the wrong key, and clank exited 1 with final output is not valid JSON; --json-schema requested. The contract held either way — a violation is a failure, not a result — but assume the grammar holds only where it has been measured. A schema and tools never share a request; with--toolsthe rounds run unconstrained and one final request carries the schema. This server build rejects the pair (400, by curl probe). Measured per model on oMLX the same day:Qwen3.5-9B-MLX-4bitreturns bare, valid JSON;gemma-4-E4B-it-MLX-4bitwraps it in a Markdown fence andMiniCPM5-2B-MLX-8bitanswers in prose, both of themexit 1. One schema, three models, two of them ignoring it — which model carries the grammar is a property to measure, not to assume. - The tools fallback exists because of a measured failure. With nothing piped and no
tools,
clank -m "which file defines the transcript rendering, and what is the output cap? cite file:line"answered "src/renderer.ts, cap 8000 atsrc/renderer.ts:14" — a file that does not exist, in the citation format of a real answer, exit 0. The same question with the tools offered answeredsrc/context.rs:68and cap400atsrc/context.rs:136, with the test that asserts it. The fallback is still a fallback: on oMLX the 2B reproduced that same invented citation with the tools on — two lookups in the stderr breadcrumbs, thensrc/renderer.ts:14again. What survives a model that fabricates is the exit code and the observable channel, not the feature. --thinking offarrives aschat_template_kwargs.enable_thinking=falseand a level asreasoning_effort, with no field at all by default and exit 2 for an unknown level. It removes reasoning on both local endpoints; an effort level is honoured by one and silently ignored by the other. Reasoning reaches stderr only under--show-thinking, and stdout stays the answer alone.cargo testdrives the real binary against a stub SSE server that records every request body, and asserts what this README and PROTOCOL.md claim: the default is one request with no tools; a schema and tools never share a request; the schema'd round carries the tool results it read; piped evidence is still context when-cis used;--eachframes one answer per item (byte-exact) and shares the prompt prefix; a failed item is anerrorevent plus exit 1 while the others are still answered; an empty item list exits 0; a truncated or empty answer exits 1 rather than reporting success; therunevent's argv has any--api-keyredacted; a trace with arunheader still reads back as context.clank-jev(the decision stage) is verified live two ways: against hosted Jev through OpenRouter's Decisions endpoint —fixtures/checks-verification.jsonreturnedinstruction_conflictandverification_contradiction, both at p ≥ 0.93, in one request — and against a localkev-0.6bon:8009, where 18 typed decisions scored 16/18 with a 17% shuffled-context control at an 83 ms median and no credentials. Its contract is guarded the same way clank's is:tests/docs.rsreads its--helpand requires every flag to appear in README.md and CHEATSHEET.md, and requires PROTOCOL.md to document exit code 3.demo.shran end to end on 2026-09-17 against:37313: four stages, 61 s, all gates passed. Its first run found a real defect — an ungated stage wrote a broken candidate and the script still announced success. On 2026-09-19 against oMLX it passed onQwen3.5-9B-MLX-4bitin 35 s, and stopped at a different gate on each of the other two: gemma on the fenced schema at stage 3, the 2B on a truncated answer at stage 1.- Live: tree context renders text, file and nested nodes in document order;
--jsonlparses withjq; repeatable-cconcatenates files in order (checked through theCLANK_DEBUGrequest dump);--systemappends the directive to the system prompt;clank --jsonl | clankandclank -c trace.jsonlrender the prior run as a tagged transcript;clank … | head -1exits 0;--list-toolsprints the four definitions without a model call; no prompt with empty stdin exits 2, an unreadable context file exits 1,--helpexits 0. --toolsis no longer unexercised: the oMLX runs sent it through the round loop end to end, with the lookups on stderr as promised. Still unexercised against a live model: the schema-after-tools sequence — the wire tests cover that request and output contract only. TTY-with-no-prompt reads one line, in code; it is not exercisable headless.
Code and docs are largely LLM-written and then read and curated. The commits are the record of what changed, when, and each of the claims under Verified was run.
