This document explains each mechanism, why it exists, and where it stops working. Read it before changing hook behaviour.
Claude Code reads files by putting their full text into the model's context. For a frontier model that is the most expensive thing a coding session does, and most of those tokens are never looked at again. Spotify's shunt plugin showed that intercepting large reads and handing them to a cheap worker model cuts bulk-read token usage by around 90% in their tests. shuntkit does the same without Spotify's backend.
Two constraints shape everything below:
- Hooks must never break a session. They run before every Read and Bash call. Any exception, any missing state, any malformed input must result in "allow". A hook that crashes is worse than no hook.
- Blocks must always say what to do instead. Claude cannot ask a human. Every deny message contains the exact command or tool call to use.
shuntkit hook read receives the PreToolUse JSON on stdin and decides:
file missing / not a regular file / image, PDF, notebook -> allow
no offset and no limit:
lines <= min_lines -> allow
lines > min_lines -> DENY (delegate)
offset and/or limit present:
lines <= min_lines -> allow
slice the call would return > min_lines -> DENY (delegate)
otherwise -> charge slice budget (see 3)
"Slice the call would return" is min(limit or 2000, lines - offset + 1).
The Read tool returns up to 2000 lines when limit is omitted, so offset
alone on a 1200-line file returns 1101 lines. Upstream allowed this and
labelled it a known bypass in its own evals. shuntkit treats it as the
whole-file read it is.
Line counting is wc -l semantics on bytes, 1 MB at a time. A 500 MB log
counts in well under a second.
shuntkit hook bash handles the ways Claude dumps files through the shell.
command contains | or > -> allow (output goes elsewhere)
shlex fails (unbalanced quotes) -> allow
leading `cd dir &&` / `cd dir;` segments -> followed; later paths resolve in dir
first remaining simple command, after env/sudo:
not in {cat nl less more bat batcat head tail sed} -> allow
head/tail with bounded count <= min_lines -> charge slice budget per large file
head/tail unbounded (-n +5, -c huge, 10k) -> treat as full read
sed -n 'A,Bp' / 'A,+Np' / 'Np' (sum of ranges) -> bounded slice, as head/tail
sed -n 'A,$p', or sed without -n -> treat as full read
sed -n '/re/p', sed -i, sed -f -> allow (filter, in-place, opaque)
full read: sum of lines over all file args
<= min_lines -> allow
> min_lines -> DENY (delegate)
Only the first command after any leading cd is inspected. echo x && cat big.txt gets through. This matches upstream and is a deliberate trade
against parsing every shell construct. sed, nl and cd are handled
because they are how Codex, which has no Read tool, reads files; Claude Code
reaches for Read by default.
Blocking the whole-file read is pointless if Claude can read the file in
four limit: 300 pieces. The budget closes that.
- Every allowed slice of a large file (over
min_lines) is charged to a counter keyed by(session_id, resolved path). - Default budget is
2 x min_lines(700 lines).SHUNTKIT_SLICE_BUDGET_LINESoverrides it;0disables. - When the counter exceeds the budget, further slices are denied with a message that names the file, the tally, the budget, and the delegation command.
- A sanction lifts the restriction.
shuntkit readsanctions every file it delegated, and a whole-file Read by the bulk-reader subagent sanctions the file too. A sanction lastsSHUNTKIT_SANCTION_TTL_SECONDS(4 hours). This keeps the natural workflow, delegate first, then take targeted reads for editing, free of interruptions.
State lives in ~/.local/state/shuntkit/slices/<session>.json and
sanctions.json. Writes are atomic (temp file plus rename). Two hooks racing
on the same session can lose one increment; the counter is a deterrent, not
an audit log, so that is accepted. Session files are pruned after 7 days once
there are more than 200 of them. Any I/O failure means "allow".
Small files are never charged. Claude reading a 200-line file in ten slices is wasteful but harmless.
shuntkit read --question Q --paths F... does, in order:
- Existence, duplicate and secret guard checks (section 6).
- Builds one message: each file in
<file path="...">tags, then the question. Refuses overSHUNTKIT_MAX_PAYLOAD_BYTES(600 kB, roughly 150k tokens, inside Haiku's 200k window with room for the answer). - Calls the transport (section 5).
- Attaches citations (section 7).
- Records a sanction for the files and appends one line to
usage.jsonl. - Prints the answer to stdout and a one-line usage summary to stderr.
shuntkit write --spec S --reference R [--context C...] builds a message
with the spec, the context files, and the reference file, calls the transport
with the writer prompt, strips a single wrapping markdown fence, and writes to
--target or stdout.
Why --context exists. The upstream design gives the worker only a
reference file, the one whose style to copy. If the spec is "tests for
OrderService" and the reference is test_user_service.py, the worker never
sees OrderService and invents method names and expected values. --context
carries the source the output is about. shuntkit write warns on stderr
when it is omitted.
A transport is invoke(system_prompt, message) -> Answer plus
check() -> list[str].
Runs the Claude Code CLI headless:
claude -p --model haiku --output-format json --tools "" --strict-mcp-config
--setting-sources "" --no-session-persistence --max-turns 1
--system-prompt "<short prompt>"
with the message on stdin, in an empty scratch directory, with CLAUDECODE
and CLAUDE_CODE_ENTRYPOINT removed from the environment.
Each flag earns its place. Measured on Claude Code 2.1.270 with a one-line prompt:
| Invocation | Input tokens | Cost |
|---|---|---|
claude -p --model haiku in a project dir |
62,549 | $0.127 |
| with the flags above | 399 to 1,367 | $0.002 |
Without --setting-sources "" and --tools "" the worker loads the parent's
CLAUDE.md, skills, and every MCP tool schema before it reads a byte of your
file. --bare would be cleaner but only accepts API-key auth, so it breaks
for OAuth and Max-plan users.
Usage and cost come from the CLI's JSON envelope (usage, total_cost_usd,
modelUsage). Latency is 3 to 15 seconds including CLI startup.
Calls client.messages.create through the official SDK with the same system
prompt and message. Model aliases map to API ids (haiku to
claude-haiku-4-5). Needs pip install 'shuntkit[anthropic]' and API
credentials. Use it to bill worker calls to an API key instead of a Claude
Code plan.
shuntkit assumes you are already a Claude user. A Claude worker needs no new vendor, key, or data-processing agreement. Other clones of the shunt idea route to Gemini or local models; if you want that, they exist. The transport interface is two methods if you want to add one here.
Delegation sends whole files off the machine under your Claude terms. Some files should never go by accident. Before any transport call, every path is checked against:
- name patterns:
.env,.env.*,*.pem,*.key,id_rsa*,credentials*,secrets.*,*.tfstate,.npmrc,.netrc, service-account JSON, and so on (full list insecrets.py); - parent directories:
.ssh,.gnupg,.aws,.azure,.kube,.docker; - content: the first 8 kB containing a PEM
PRIVATE KEYblock.
A match refuses the whole call and names the files. This is a guard rail. It
does not scan for tokens inside ordinary source files. SHUNTKIT_SECRET_GUARD=off
disables it.
The hooks themselves send nothing anywhere. They only count lines locally.
The standing complaint about delegated summaries is that they come without line numbers, so Claude has to read the file anyway before editing.
The reader prompt asks the worker to quote distinctive fragments verbatim in
backticks. After the answer comes back, every backticked span of 12 to 240
characters with at least six alphanumerics is searched for in the delegated
files. A span found exactly once gets (file:line) appended. Found zero
or several times, it is left alone. Nothing the worker wrote is removed or
altered; only suffixes are added.
The result: def load_config(path): (config.py:3) is a line number Claude
can trust without reading the file, because shuntkit computed it, not the
worker. The skill text tells Claude that unsuffixed quotes are unverified.
SHUNTKIT_DELEGATE=subagent changes what the deny messages say. Instead of
the shuntkit read command, they point at the bulk-reader subagent
(shuntkit:bulk-reader when installed as a plugin). The agent definition
pins model: haiku, allows only Read, Glob and Grep, and forbids Agent,
Bash, Write and Edit.
The hook recognises tool calls whose agent_type is bulk-reader or
shuntkit:bulk-reader and lets them through unconditionally. A whole-file
Read by that agent sanctions the file for the parent's slice budget.
Trade-offs against CLI mode:
| CLI mode | Subagent mode | |
|---|---|---|
| Round trip | new claude process, 3 to 15 s |
in-process, usually faster |
| Worker context | ~400 to 1,400 tokens of scaffolding | full subagent system prompt, several thousand tokens |
| Secret guard, payload cap, citations, usage log | yes | no (the agent reads files itself) |
| Works outside Claude Code | yes | no |
CLI mode is the default because the guards apply. Subagent mode is there for people who prefer Claude Code's native mechanism.
pip / uv / pipx. shuntkit install writes two PreToolUse entries into a
settings.json, the two SKILL.md files into skills/, and the agent into
agents/ under one Claude settings directory. shuntkit uninstall removes
exactly those entries and files. There are two scopes:
--project(default) targets./.claude. Only that repository gets the hooks, and the files are meant to be committed. The hook command is the bare nameshuntkit, because an absolute path from one developer's machine would break for everyone else. It must therefore be onPATHwhen Claude Code starts;--commandoverrides it.--usertargets~/.claudeand applies to every project. The hook command is the absolute path of the executable, because hooks run in a non-login shell where~/.local/binis frequently absent fromPATH.
Per project is the default because the hooks change how the model reads files and spend money on every delegation. Opting a repository in is a smaller decision than opting a whole machine in, and it mirrors how other project-specific hooks (lint gates, test runners) are set up. The upstream plugin is user-wide because plugins can only be.
Codex. shuntkit install --host codex writes only the Bash hook, into
.codex/hooks.json (project) or ~/.codex/hooks.json (user), with the
command shuntkit hook bash --host codex. Codex will not run a hook until
the user has trusted it with /hooks, and project hooks load only inside a
trusted project; the install prints both facts because it cannot do either.
Codex's plugin manifest validator rejects a hooks field, so there is no
plugin form for Codex.
Plugin. The repo root is a Claude Code plugin. hooks/hooks.json runs
python3 -m shuntkit with PYTHONPATH set to the plugin's src/, so
nothing is pip-installed. The checked-in skills/ and agents/ files are
rendered from src/shuntkit/skills.py; a test fails when they drift.
The hooks were written for Claude Code. Codex CLI was verified against version 0.153.4 (September 2026) and differs in these ways:
| Claude Code | Codex | |
|---|---|---|
| Hook event and deny JSON | PreToolUse, hookSpecificOutput.permissionDecision |
Identical |
| Shell tool | Bash, tool_input.command |
Identical |
| File reads | Read tool with offset/limit |
Shell only: sed -n, cat -n, nl, rg -C |
| Hook files | settings.json |
hooks.json, or [hooks] in config.toml |
| Trust | None | /hooks review per hook; project must be trusted |
| Plugins can ship hooks | Yes | No (manifest validator rejects hooks) |
| Subagent identity in payload | agent_type |
None; subagents share the parent session_id |
| Skills / Agent tool | Yes | Not used by shuntkit |
Consequences. The Read hook is not installed on Codex. Block messages are
worded per host (--host codex on the hook command, or SHUNTKIT_HOST):
Codex is told to run shuntkit read ... as a shell command and to slice
with sed -n 'A,Bp', and is never pointed at a skill or the Agent tool.
Subagent mode is Claude-only, because without an identity field the hooks
cannot exempt a Codex subagent's reads. The CLI worker needs no exemption on
either host: it is a separate process with hooks disabled.
The deny reason does reach the Codex model. In the verification run it
reported back "a PreToolUse hook blocked cat big.txt because the file has
500 lines, exceeding the 350-line threshold" and stopped.
- Windows is unsupported. Hook commands assume a POSIX shell and
python3. - Shell parsing is shallow. Heredocs, subshells,
xargs cat,awk,grep -n "" big.txt,python -c "print(open(...).read())"are not recognised. The hooks target what the model actually does, not what a determined user could do. - Codex delegation needs a Claude transport. On Codex the worker is still
the Claude CLI or the Anthropic API. A
codex exectransport is on the roadmap. - Slice budget races. Parallel tool calls on the same file can each read the counter before either writes it. One increment is lost. Acceptable.
--max-turns 1means the worker cannot call tools, by design. It answers from the corpus it was given.- Citations need distinctive quotes. A quote like
return Noneappears many times and is left unannotated. The prompt asks for distinctive fragments, but the worker does not always comply. - The worker is Haiku. It finds structure and surface patterns well. It misses subtle bugs. Spotify's write-up says the same about their worker. Debugging, architecture and safety-critical review stay with the frontier model.
- Savings are per bulk read, not per session. A session that never touches a large file saves nothing and pays a few milliseconds per tool call for the hook.