cancho-tools
Ten small tools that AI agents and scripts can rely on, and one opt-in. seek, list, peek, jsonq, tally, hash, write, replace, move and archive do the everyday work of grep, ls/find, sed -n, jq, sort | uniq -c, sha256sum, a careful cp/mv and tar/gzip, and run starts a program from a directory you chose, with explicit semantics, bounded resources, tagged refusals that often carry a repair, and a checkable authority row per binary. Every answer is JSON against a published schema, and every tool describes itself (introspect, skill); an MCP server serves them to an agent runtime from that same description. Written in cancho. The project page has one page per tool.
Status: early, ten tools and one opt-in built and tested. Not claimed: that agents do better with these tools than with the incumbents. That needs an agent-in-the-loop evaluation (cancho#228) that has not been run. What is claimed is what the conformance gates check. What is missing is listed under "What it cannot do".
| Tool | Instead of | Does |
|---|---|---|
seek | grep -F -n -b | literal search over files, a stream of matches |
list | ls, find, tree | the entries beneath a directory, sorted, depth-bounded, links never followed |
peek | sed -n, head, tail, wc -l | a range of lines or bytes, with numbers and a cursor |
jsonq | jq -c | one value from one strict JSON document, by RFC 6901 pointer |
tally | sort | uniq -c | sort -rn | distinct lines or fields, counted and ranked |
hash | sha256sum, sha512sum | file digests of any size, --verify |
write | cp, >, tee | replace a file atomically, only if it holds what the caller says |
replace | sed -i | replace exact text an expected number of times, atomically |
move | mv | rename a file or directory in place, never over an existing name |
archive | tar, gzip, unzip, zstd | list, extract and create tar archives, gzip- or zstd-compressed or not, and zips; read bzip2 and xz; checked whole before any file is written (docs/archive.md) |
run | exec, timeout | opt-in: start one program from the deployer's directory with exact arguments, a fixed environment, a deadline and an output cap (docs/next-tools.md section 3) |
The tools at a glance
seek
Search files for a literal string; NDJSON records with tagged errors, bounded memory, and an authority with no write, network or foreign code.
write
Replace a file atomically, only if it holds what the caller says it holds (--if-sha256) or does not exist (--create); idempotent, locked, with --dry-run.
replace
Replace exact text in a file atomically, only when it occurs --expect times (and, with --if-sha256, only when the file is what the caller read); idempotent, locked, with --dry-run.
peek
Read a range of a file -- lines with numbers and offsets, or bytes -- with its size, a binary flag and a cursor to the rest; head, tail, sed -n, cat -n and wc -l in one call.
jsonq
Look up one JSON Pointer (RFC 6901) in one strictly parsed JSON document; the value byte for byte, its kind, keys or length, and a parse error's position as data.
tally
Count distinct lines or fields and report the most frequent, ordered by count then bytewise key; one bounded pass, the same answer on every machine.
hash
SHA-256 or SHA-512 of files of any size, as NDJSON records, with --verify; the hash write --if-sha256 wants, from a tool that cannot write.
list
The entries beneath a directory, sorted bytewise, depth-bounded and capped, as NDJSON records; links listed and never followed, and no verb but reading.
move
Rename a file or directory in place, never over an existing name (the kernel refuses it, even if another process creates it mid-move); locked, atomic, idempotent with --if-sha256, with --dry-run. One directory: a move to another is not offered. With --remove and --if-sha256, a file is removed by renaming it to a tombstone, and nothing is deleted.
archive
List, extract and create tar archives, gzip- or zstd-compressed or not (and read bzip2- and xz-compressed ones), and zips, and compress one file with gzip or zstd; refuses links, absolute names, .. and overwriting by rule, checks the whole archive before writing any of it, and writes the same bytes for the same files.
run
Start one program from the deployer's programs directory with exact arguments, no shell, a fixed environment, a deadline and an output cap; answers its exit status and output. Opt-in: it can run whatever the directory holds, and it does not sandbox it.
One complete flow: two agents, one file
Two agents read notes.txt and each adds a line. With plain writes the second write wins and agent A's line is gone, and nobody is told:
$ printf 'shopping list\n- milk (agent A)\n' > notes.txt # agent A
$ printf 'shopping list\n- bread (agent B)\n' > notes.txt # agent B
$ cat notes.txt
shopping list
- bread (agent B)
With guarded writes (write --if-sha256) the stale write is refused, and the refusal carries what the agent needs to retry (selected fields of each JSON line):
$ hash --root . notes.txt # A and B both read it: H1 is its hash
{"type":"hash","path":"notes.txt","algo":"sha256","hex":"936bb8ff57a0…","bytes":14}
$ ... | write --root . --if-sha256 H1 --stdin notes.txt # agent A adds its line
{"path":"notes.txt","changed":true,"bytes":31,"after_sha256":"7bde7db5f30a…"}
$ ... | write --root . --if-sha256 H1 --stdin notes.txt # agent B, stale
{"rule":"precondition.hash-mismatch","hint":"re-read the file, then decide","repair":{"kind":"none","reason":"the file changed since it was read; re-read, then decide"},"detail":{"path":"notes.txt","expected_sha256":"936bb8ff57a0…","actual_sha256":"7bde7db5f30a…","size":31}}
# exit status 5
$ ... | write --root . --if-sha256 H2 --stdin notes.txt # agent B re-reads (H2 came with the refusal), re-applies, retries
{"path":"notes.txt","changed":true,"bytes":49,"after_sha256":"60e7843a980c…"}
$ cat notes.txt
shopping list
- milk (agent A)
- bread (agent B)
The full transcript, with every JSON line as printed, is in docs/reference.md.
Why these tools
grep, sed and jq were made for a person at a terminal, who reads the prose and retries. An agent has to parse text, often cannot see stderr, and decides its next step from what came back. Each bullet is a property the gates check, with where to see it.
- Records with a schema. Every output is JSON against a published schema (
schemas/). A path, a line number and a byte offset are fields; bytes that are not UTF-8 come back as{"b64": …}, never replaced. - Knows when it has everything. A stream ends with an
endrecord, so a stream without one was cut short. A capped answer saystruncated: trueand gives anextcursor to resume exactly there. - Every refusal is a rule. An error is
{code, rule, message, hint, repair, detail}; match onrule. When the fix is mechanical,repairis an argv to run as it stands, and a repair never widens what the tool may touch.seek introspectlists the rules and exit codes. - States what it expects.
writeneeds--createor--if-sha256of what it read, andreplaceneeds the text to occur--expecttimes: a stale view isprecondition.hash-mismatch, not a lost edit. Writes are atomic, locked and idempotent, and--dry-runshows the diff. - **Stays under
--root.**.., absolute and outside paths are refused before anything is opened, and no link below the root is followed (path.symlink). - The same bytes every time. No locale, no clock, no environment.
- Bounded memory. Streaming tools hold one chunk plus the longest line, capped by
--max-line-bytes: about 1.7 MB whether the file is 1 MiB or 256 MiB (reference). - Self-describing.
introspectprints the flags, rules, exit codes, limits, schema and guarantees as JSON;skillprints a guide for an agent. Both come from the tables the parser runs on, so they cannot drift. - A checkable authority. Each binary embeds what the compiler derived it may do: no network, no foreign code, no clock, and no write except beneath a directory it opened. CI fails if a binary exceeds its ceiling in
tools.toml.
Quick start
You need git, Rust and clang. On a 16-core Mac with Rust installed and its dependencies already downloaded, the compiler built in 17 seconds and the first nine tools in 7; the clones and a first-time dependency download are extra, so allow a few minutes the first time.
git clone https://github.com/alpibrusl/cancho
git clone https://github.com/alpibrusl/cancho-tools && cd cancho-tools
REV=$(sed -n 's/^cancho *= *"\([0-9a-f]*\)".*/\1/p' cancho.toml) # the compiler these sources need
(cd ../cancho && git fetch -q origin && git checkout "$REV" && cargo build --release -p cancho)
export PATH=$PWD/../cancho/target/release:$PWD/build:$PATH
cancho build # every tool into build/
seek --root . --format text 'fn main' tools/seek/seek.cho # try one
seek introspect # flags, rules, limits, schema, authority (JSON)
What you can do now
One real command per tool, on a small project (src/main.rs, package.json, access.log). Every tool takes --root .: paths are relative to it and nothing outside can be reached. The output is what the built tools print, with long hashes shortened.
**seek**: search files for literal text.
$ seek --root . add src/main.rs
{"type":"match","path":"src/main.rs","line":2,"offset":12,"text":" let total = add(2, 3);"}
{"type":"match","path":"src/main.rs","line":6,"offset":69,"text":"fn add(a: i32, b: i32) -> i32 {"}
{"type":"file","path":"src/main.rs","matches":2,"bytes":113,"binary":false,"complete":true}
{"type":"end","ok":true,"command":"seek","schema":"seek.v1","complete":true,"files":1,"matches":2,"errors":0,"truncated":false,"next":null}
**list**: the entries beneath a directory.
$ list --root . --depth 2 --format text
access.log
package.json
src/
src/main.rs
**peek**: a range of lines, with numbers.
$ peek --root . --lines 2:3 --format text src/main.rs
2 let total = add(2, 3);
3 println!("{}", total);
**jsonq**: one value from a JSON document.
$ jsonq --root . -p /deps/serde package.json
{"ok":true,"command":"jsonq","schema":"jsonq.v1","data":{"pointer":"/deps/serde","kind":"string","value":"1.0"},"meta":{"version":"0.1.0"}}
**tally**: count and rank a field.
$ tally --root . --field 3 --delim ' ' --format text access.log
3 200
1 404
1 500
**hash**: a file's SHA-256.
$ hash --root . package.json
{"type":"hash","path":"package.json","algo":"sha256","hex":"2c313334b372…","bytes":78}
{"type":"end","ok":true,"command":"hash","schema":"hash.v1","complete":true,"files":1,"errors":0}
**write**: create a file, or replace it only if it is what you think.
$ printf 'hello\n' | write --root . --create --stdin NOTES.md
{"ok":true,"command":"write","schema":"write.v1","data":{"path":"NOTES.md","changed":true,"created":true,"bytes":6,"before_sha256":null,"after_sha256":"5891b5b522d5…","diff":[{"old_start":1,"new_start":1,"removed_count":0,"added_count":1,"removed":[],"added":["hello\n"]}],"diff_truncated":false},"meta":{"version":"0.1.0"}}
**replace**: change exact text, here as a dry run.
$ replace --root . --old 'a + b' --new 'a.wrapping_add(b)' --dry-run src/main.rs
{"ok":true,"command":"replace","schema":"replace.v1","data":{"path":"src/main.rs","changed":false,"replacements":1,"bytes":125,"before_sha256":"98ace4483c48…","after_sha256":"63261420d975…","diff":[{"old_start":7,"new_start":7,"removed_count":1,"added_count":1,"removed":[" a + b\n"],"added":[" a.wrapping_add(b)\n"]}],"diff_truncated":false},"dry_run":true,"planned_actions":[{"op":"replace","path":"src/main.rs","replacements":1,"before_sha256":"98ace4483c48…","after_sha256":"63261420d975…","bytes":125,"diff":[{"old_start":7,"new_start":7,"removed_count":1,"added_count":1,"removed":[" a + b\n"],"added":[" a.wrapping_add(b)\n"]}],"diff_truncated":false}],"meta":{"version":"0.1.0"}}
# exit status 9
**move**: rename in place, never over an existing name.
$ move --root . --dry-run NOTES.md NOTES-2026.md
{"ok":true,"command":"move","schema":"move.v1","data":{"path":"NOTES.md","to":"NOTES-2026.md","changed":false,"kind":"file","sha256":null},"dry_run":true,"planned_actions":[{"op":"move","path":"NOTES.md","to":"NOTES-2026.md","kind":"file","sha256":null}],"meta":{"version":"0.1.0"}}
# exit status 9
**archive**: a tarball, byte for byte the same every time, here as a dry run.
$ archive --root . --op create --dry-run project.tgz src package.json
{"type":"dir","path":"src"}
{"type":"file","path":"src/main.rs","bytes":113,"sha256":"98ace4483c48…"}
{"type":"file","path":"package.json","bytes":78,"sha256":"2c313334b372…"}
{"type":"end","ok":true,"command":"archive","schema":"archive.v1","complete":true,"op":"create","format":"tgz","entries":3,"bytes":191,"errors":0,"dry_run":true,"archive_sha256":"372e8efb591b…"}
# exit status 9
Flags, more examples, and each tool's rules are in docs/reference.md.
As an MCP server
server/mcp.cho serves the ten default tools over MCP (run only if the server is built with --with run) (stdio, protocol 2025-06-18). tools/list is generated from each tool's introspect, and a tools/call runs the binary and answers with its output byte for byte. It may start the binaries in the one directory it was built with, and nothing else; --root is the server's, never the model's.
python3 scripts/mcp.py build --bin "$PWD/build" --out build/mcp
build/mcp --root /path/to/workspace
Design and measurements: docs/mcp.md.
What "designed for agents" means here, what a test checks on every binary, and what is not checked: docs/agent-first.md.
What it cannot do
- No delete. No tool removes a file.
move --remove --if-sha256 HEXrenames it to a tombstone in the same directory, andmove NAME.removed-… NAMEundoes it. - **No regular expressions, no diff tool, no network fetch (and no
curlorssh: a network tier would be a separate project with its own authority ceiling, docs/archive.md section 9).**seekis literal text;jsonqtakes pointers, notjqfilters. - Same-directory renames only.
movetakes a new name, not a new directory. - **
seektakes named files,** not a directory:listthenseekis the pattern for now. - No process spawning, no network, no clock in any tool but one:
run, which is opt-in, holdsexecfor its programs directory and aclockfor its deadline, and says it isirreversible-consequential. No other tool's authority holds them (tools.toml). - **
archiveholds the archive in memory,** up to--max-bytes, so its memory is that bound and not a constant.extractmakes the directories an entry needs beneath--into, never--intoitself (docs/archive.md section 13). It does not write bzip2 or xz. - SHA-256 is cancho's own until it moves into the standard library (cancho#219).
The list of open work is in issues.
How fast
Supporting evidence: these tools are not built to win on speed, and mostly do not lose. A 64 MiB text file, through a pipe, minimum of 7 interleaved rounds, Linux x86-64 on a shared machine; speed is reported, not gated.
| Command | cancho-tools | Incumbent |
|---|---|---|
seek gamma (about a million matches) | 0.49 s | grep -F -n -b 0.51 s, rg 0.39 s |
seek with no match | 0.04 s | grep -F -n -b 0.04 s |
hash (SHA-256) | 0.52 s | sha256sum 0.17 s |
peek --count-lines --lines 1:100 | 0.08 s | sed -n 1,100p; wc -l 0.13 s |
tally --field 1 | 0.16 s | cut | sort | uniq -c | sort | head 0.68 s |
jsonq -p /0, 16 MiB array | 0.14 s | jq -c .[0] 0.73 s |
list, one directory of 100,000 files | 0.144 s | find -printf 0.146 s, ls -1 0.154 s |
sha256sum's lead is OpenSSL's hand-written assembly, which cancho has no way to express. More rows (seek -i is 0.58 s against 0.52 s), startup and --root cost: docs/reference.md. Measure on your machine: python3 scripts/toolbench.py --tool seek --size 64MiB.
Learn more
| project page | one page per tool, generated from the tool itself: flags, rules, exit codes, authority, JSON schemas and a ready-made SKILL.md |
| docs/reference.md | every tool with its examples, the error protocol, confinement, authority, memory, performance, the package, development |
| docs/mcp.md | the MCP server: design and measurements |
| docs/history.md | how this was built, what it found in the compiler, the performance rounds |
cancho's docs/agent-toolbox.md | the design (epic cancho#214) |
| cancho-table | a tenth tool, built outside this repository on its contract package |
Contributing
Every change goes through what CI runs: cancho fmt --check contract tools tests generated server, cancho build, cancho test, scripts/schemas.py --check, scripts/manifest.py --check, scripts/demo_race.py --check, scripts/package.py --check, scripts/mcp.py --check, scripts/cli_mutants.py --check (the mutants of the table scans), the conformance tests. A change that needs a new effect fails until tools.toml allows it. After changing a tool, run python3 scripts/manifest.py and cancho build; after changing what a tool prints, python3 scripts/demo_race.py --update-readme rewrites the generated sections of this file. Layout, the gates and how to build more tools on the contract package: docs/reference.md.