Anatomy of a token filter: RTK under the microscope
TL;DR. Chapter 26 drove RTK as a user; Chapter 27
benchmarked it. This chapter opens it up. We read the actual v0.43.0 source (Apache-2.0, about
20,000 lines of Rust) and replay every stage live on this machine: the PreToolUse hook that swaps
a command mid-flight with a 16-line JSON reply, the registry that rewrites 82 command families and
refuses anything it cannot attest, the two-stage git log filter that starts saving tokens
before the command even runs, the never_worse guard, the ceil(chars/4) ledger in SQLite, and
the tee escrow that keeps every dropped byte recoverable. The dissection also surfaces something
you can only see at this level: RTK's own meter underreports its savings, because the biggest
lever (argument injection) fires before the measurement starts. On our lab repo, git log really
shrinks 597 tokens to 228 (a 62 percent cut) while the ledger records 17.4 percent. The chapter
ends with the five design rules worth stealing for any filter you build yourself.
Contents
- Why dissect a filter at all
- One prompt, end to end
- Stage 0: the hook, sixteen lines that swap your command
- The rewrite registry, and when it refuses
- Stage 1: argument injection, filtering before the command runs
- Stage 2: the post-filter
- The lab: watching both stages on a real repo
- The safety nets: never_worse and the tee escrow
- The ledger: ceil(chars/4), and what the meter cannot see
- The long tail: 63 filters in TOML
- What to steal for your own filters
- Takeaways
Why dissect a filter at all
Because "how does it know the LLM will read this?" is the question that separates people who use context tools from people who understand them. RTK advertises itself as a token-saving command proxy, and Chapter 27 confirmed the headline on this machine's own traffic. But a benchmark treats the tool as a black box. This chapter treats it as a white box, for two reasons.
First, RTK is the cleanest available specimen of a whole species: the tool-result filter, a program that sits at the exact choke point where command output becomes model context. Everything it does (rewrite, shape, cut, guard, meter, escrow) is something any filter at that choke point must decide about, including one you write yourself in an afternoon.
Second, the source is public and small enough to actually read. The Homebrew formula points at the repository, so we can fetch the exact code that produced the binary on this machine:
brew cat rtk | head -6
class Rtk < Formula
desc "CLI proxy to minimize LLM token consumption"
homepage "https://www.rtk-ai.app/"
url "https://github.com/rtk-ai/rtk/archive/refs/tags/v0.43.0.tar.gz"
sha256 "196bec9e9b438f0b8cd0198f68e05f072ccdfdec2c2655a3562d6ea357fa485b"
license "Apache-2.0"
Every source excerpt below is copied verbatim from that v0.43.0 tarball, and every command output is real, captured on this machine. Where line numbers matter we cite the file so you can follow along in your own checkout.
One prompt, end to end
Start with the guarantee, because everything else hangs off it. Suppose you ask the agent:
"What changed in the last three commits?"
The model answers by emitting a tool_use block, the only way it can touch your machine
(Chapter 17 walked this loop):
{"name": "Bash", "input": {"command": "git log --stat -n 3"}}
Claude Code is about to execute that command, but the user's settings.json registers a
PreToolUse hook (Chapter 28 covered the hook channel):
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "rtk hook claude" } ] }
]
}
So before execution, the harness pipes the tool call to rtk hook claude on stdin. We can
impersonate the harness with printf and watch the exchange:
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git log --stat -n 3"}}' \
| rtk hook claude
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecisionReason":"RTK auto-rewrite","updatedInput":{"command":"rtk git log --stat -n 3"}}}
That updatedInput field is the whole trick. The harness replaces the model's command with the
rewritten one, executes rtk git log --stat -n 3, and whatever that prints to stdout becomes the
tool_result block appended to the conversation. The Claude API is stateless: on every turn the
harness resends the entire message history, tool results included, as input tokens
(Chapter 23 dissected exactly which usage bucket they land in). There is
no side channel where output could go instead. So the answer to "how is consumption guaranteed?"
is: it is not detected, it is structural. The hook installed itself at the one point in the
loop where every byte is already destined for the context window.
Don't be confused. The hook rewrite is not a shell alias or a PATH shim. An alias changes what your terminal runs; it cannot see the agent's tool calls, which never pass through your shell profile. The hook operates on the tool call itself, inside the harness, before a shell is even spawned. Your interactive
git logis untouched.
The rest of this chapter follows those tokens through RTK's insides: what decides the rewrite, what shapes the output, what guards the result, and what the meter writes down.
Stage 0: the hook, sixteen lines that swap your command
rtk hook claude reads at most 1 MiB of stdin (STDIN_CAP in src/hooks/hook_cmd.rs), parses
it as JSON, and pattern-matches the tool name. The detection is deliberately narrow:
#![allow(unused)] fn main() { // src/hooks/hook_cmd.rs fn detect_format(v: &Value) -> HookFormat { // VS Code Copilot Chat / Claude Code: snake_case keys if let Some(tool_name) = v.get("tool_name").and_then(|t| t.as_str()) { if matches!(tool_name, "runTerminalCommand" | "Bash" | "bash") { if let Some(cmd) = v .pointer("/tool_input/command") .and_then(|c| c.as_str()) .filter(|c| !c.is_empty()) { return HookFormat::VsCode { command: cmd.to_string() }; } } return HookFormat::PassThrough; } ... }
Anything that is not a shell command passes through in silence. We can prove the two silent paths
live. A Read tool call produces no output at all, so the harness proceeds unmodified:
printf '%s' '{"tool_name":"Read","tool_input":{"file_path":"/etc/hosts"}}' | rtk hook claude
And so does a Bash command containing a command substitution:
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git log $(date +%F)"}}' | rtk hook claude
That second silence is a security decision, and we will come back to it in the next section.
When the hook does rewrite, one more subtlety hides in the reply. Compare the JSON we captured above with the source that builds it:
#![allow(unused)] fn main() { // src/hooks/hook_cmd.rs, process_claude_payload() let mut hook_output = json!({ "hookEventName": PRE_TOOL_USE_KEY, "permissionDecisionReason": "RTK auto-rewrite", "updatedInput": updated_input }); if allow { hook_output .as_object_mut() .unwrap() .insert("permissionDecision".into(), json!("allow")); } }
permissionDecision: "allow" is inserted only when allow is true, and allow comes from
checking the command against the user's own Claude Code permission rules
(src/hooks/permissions.rs reads ~/.claude/settings.json, the project's .claude/settings.json,
and settings.local.json, in that order). Our captured reply had no permissionDecision field:
this box has no allow rule for git log, so RTK rewrote the command but stayed silent on
permissions, leaving Claude Code's normal prompt flow in charge of the rewritten command. A hook
that blanket-allowed everything it rewrote would be quietly escalating its own privileges. This
one refuses to.
The rewrite registry, and when it refuses
The decision "does this command have an RTK equivalent?" lives in src/discover/registry.rs, a
table of 82 command families, each mapping a raw command pattern to its rtk equivalent plus
a category and an estimated savings percentage. The unit tests show the shape of an entry:
#![allow(unused)] fn main() { // src/discover/registry.rs (test) assert_eq!( classify_command("git status"), Classification::Supported { rtk_equivalent: "rtk git", category: "Git", estimated_savings_pct: 70.0, status: RtkStatus::Existing, } ); }
You can call the classifier yourself through rtk rewrite, which prints the rewritten command
and reports its verdict in the exit code. The doc comment in src/hooks/rewrite_cmd.rs is the
contract:
#![allow(unused)] fn main() { /// | Exit | Stdout | Meaning | /// |------|----------|--------------------------------------------------------------| /// | 0 | rewritten| Rewrite allowed — hook may auto-allow the rewritten command. | /// | 1 | (none) | No RTK equivalent — hook passes through unchanged. | /// | 2 | (none) | Deny rule matched — hook defers to Claude Code native deny. | /// | 3 | rewritten| Ask rule matched — hook rewrites but lets Claude Code prompt.| }
Live, on this machine:
rtk rewrite "git log --stat -n 3"; echo " <- exit $?"
rtk rewrite "htop"; echo "(no output) <- exit $?"
rtk git log --stat -n 3 <- exit 3
(no output) <- exit 1
Exit 3, not 0, for the same reason the hook reply omitted permissionDecision: no allow rule
matches here, so the rewrite happens but permission stays with the harness. htop has no entry
in the registry, so it passes through untouched.
Before the registry is even consulted, the command must survive a screening for constructs the
rewriter cannot reason about. This is the source of the silent treatment our
git log $(date +%F) received:
#![allow(unused)] fn main() { // src/discover/lexer.rs pub fn contains_unattestable_construct(cmd: &str) -> bool { if contains_substitution(cmd) { return true; } let tokens = tokenize(cmd); tokens .iter() .enumerate() .any(|(i, tok)| tok.kind == TokenKind::Redirect && redirect_has_file_target(&tokens, i)) } }
Command substitution ($(...) or backticks), process substitution, and redirects into files all
disqualify a command from rewriting, and the lexer is quote-aware: bash expands $(...) inside
double quotes but not single quotes, and the lexer knows the difference. The reasoning is worth
internalizing. A rewriter that touches git log $(deploy.sh) might change when or whether the
substitution runs; a filter that intercepts foo > out.txt would capture bytes the user meant
for a file. When RTK cannot attest that the rewritten command is behaviorally identical, it
declines to rewrite at all. The registry also refuses heredocs and arithmetic expansion, and it
normalizes backslash-newline line continuations before matching, closing a bypass where a leading
continuation defeated the matcher (the comment in registry.rs cites the project's issue #1564).
Compound commands get split on &&, ||, ;, and |, and each segment is rewritten
independently, which is why a chain like git status && git log becomes
rtk git status && rtk git log.
Stage 1: argument injection, filtering before the command runs
Here is the part that surprised us, and that no amount of black-box benchmarking reveals. RTK's
git log filter does most of its work before git produces a single byte, by rebuilding the
argument list. From src/cmds/git/git.rs:
#![allow(unused)] fn main() { // src/cmds/git/git.rs, run_log() // Apply RTK defaults only if user didn't specify them // Use %b (body) to preserve first line of commit body for agent context if !has_format_flag { cmd.args(["--pretty=format:%h %s (%ar) <%an>%n%b%n---END---"]); } // Determine limit: respect user's explicit -N flag, use sensible defaults otherwise let (limit, user_set_limit) = if has_limit_flag { let n = parse_user_limit(args).unwrap_or(10); (n, true) } else if has_format_flag { cmd.arg("-50"); (50, false) } else { cmd.arg("-10"); (10, false) }; }
Unless you asked for a specific format, RTK swaps git's default four-line-header,
full-body presentation for a one-line header (hash subject (relative date) <author>) followed by
the body and an ---END--- sentinel that stage 2 will use to find block boundaries. Unless you
asked for a count, it caps history at 10 commits, and it appends --no-merges. Note the
politeness protocol running through the whole function: every injection checks whether the user
(or the model) already expressed an opinion, and defers if so. A model that deliberately runs
git log -30 --pretty=fuller gets exactly that.
This is context engineering's cheapest trick, and it is worth a box because it generalizes far beyond RTK.
Don't be confused. Filtering output and asking for less output are different levers. Most CLI tools have flags that shape their own output (
--statvs-p,--oneline,--porcelain,-n); pulling those flags costs nothing and loses nothing that was requested. Post-hoc filtering can only delete what already got generated. RTK pulls the flag lever first and the deletion lever second, and as we will see, its own meter only watches the second.
Stage 2: the post-filter
What git prints then flows through filter_log_output, which splits on the injected ---END---
sentinel and compresses each commit block:
#![allow(unused)] fn main() { // src/cmds/git/git.rs, filter_log_output() // Remaining lines are the body — keep up to 3 non-empty, non-trailer lines let all_body_lines: Vec<&str> = lines .map(|l| l.trim()) .filter(|l| { !l.is_empty() && !l.starts_with("Signed-off-by:") && !l.starts_with("Co-authored-by:") }) .collect(); }
Headers get truncated to 80 columns (120 if you set your own -n, on the theory that an explicit
count signals you want more detail per commit), bodies keep at most three non-empty lines, and
Signed-off-by: / Co-authored-by: trailers are dropped entirely: ceremony a model never needs
to see. The first line of a commit body survives on purpose. The comment in run_log explains
why: bodies carry BREAKING CHANGE notes and design rationale, exactly the lines an agent asked
to summarize history actually wants.
The lab: watching both stages on a real repo
Reading code tells you intent; running it tells you truth. We build a disposable repo whose history exercises every rule we just read: 13 commits (three past the cap), one commit with a long subject, a multi-line body, and both trailer types.
mkdir rtk-lab && cd rtk-lab && git init -q filter-lab && cd filter-lab
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
echo "line $i" >> app.py
git add app.py
git commit -q -m "feat: add feature $i to the parser module"
done
echo notes > notes.md && git add notes.md
git commit -q -m "fix: stop the tokenizer from splitting emoji into surrogate halves" -m "The old code indexed by UTF-16 code unit, so any astral-plane character
was cut in half and produced two replacement glyphs downstream.
This switches the whole pipeline to char indices.
Signed-off-by: Ada <ada@lab.dev>
Co-authored-by: Grace <grace@lab.dev>"
(Your hashes and relative dates will differ; everything else reproduces.) Raw git first. The newest commit alone spends 14 lines before history even starts:
git log > raw.txt
head -14 raw.txt
commit 29b02ab1d6c6e745b4e8e3987fff4d279544943b
Author: Ada <ada@lab.dev>
Date: Thu Jul 2 23:32:16 2026 -0400
fix: stop the tokenizer from splitting emoji into surrogate halves
The old code indexed by UTF-16 code unit, so any astral-plane character
was cut in half and produced two replacement glyphs downstream.
This switches the whole pipeline to char indices.
Signed-off-by: Ada <ada@lab.dev>
Co-authored-by: Grace <grace@lab.dev>
Now the filtered version:
rtk git log > filtered.txt
cat filtered.txt
29b02ab fix: stop the tokenizer from splitting emoji into surrogate halves (0...
The old code indexed by UTF-16 code unit, so any astral-plane character
was cut in half and produced two replacement glyphs downstream.
This switches the whole pipeline to char indices.
ce2eaf5 feat: add feature 12 to the parser module (0 seconds ago) <Ada>
b836d16 feat: add feature 11 to the parser module (0 seconds ago) <Ada>
caadde4 feat: add feature 10 to the parser module (0 seconds ago) <Ada>
879920b feat: add feature 9 to the parser module (0 seconds ago) <Ada>
441dc8d feat: add feature 8 to the parser module (0 seconds ago) <Ada>
3c4f6e8 feat: add feature 7 to the parser module (0 seconds ago) <Ada>
eac4c13 feat: add feature 6 to the parser module (1 second ago) <Ada>
bdaa746 feat: add feature 5 to the parser module (1 second ago) <Ada>
3ad3d29 feat: add feature 4 to the parser module (1 second ago) <Ada>
Every rule from the source is visible in the output. Thirteen commits became ten (the injected
-10). The long header hit the 80-column knife mid-parenthesis ((0...). The body kept its
three content lines. Both trailers vanished. And features 1 through 3 are simply gone, which is
the honest cost of the cap: stage 1 is lossy, and Chapter 27's central
lesson applies to it (a ratio without a loss inspection is meaningless). The byte count:
wc -c raw.txt filtered.txt
2387 raw.txt
912 filtered.txt
3299 total
A 62 percent cut on this history. The --stat variant from our end-to-end prompt compresses less
dramatically (939 to 577 bytes) because file-change tables are already dense, which matches the
per-command spread Chapter 27 measured: the wordier the raw output, the
bigger RTK's bite.
The safety nets: never_worse and the tee escrow
Two mechanisms keep the filtering from ever becoming a liability, and both are small enough to
quote whole. The first guards the size direction. Every filtered result passes through
src/core/guard.rs on its way out:
#![allow(unused)] fn main() { //! Never-worse output guard: RTK never emits more tokens than the raw command. use crate::core::tracking::estimate_tokens; /// Returns `filtered`, or `raw` when `filtered` would emit more tokens. pub fn never_worse<'a>(raw: &'a str, filtered: &'a str) -> &'a str { if estimate_tokens(filtered) > estimate_tokens(raw) { raw } else { filtered } } }
If a filter's decorations (match counts, headers, escrow pointers) ever cost more than they save, the raw output ships instead. A filter with this guard can be wrong but never counterproductive.
The second net guards the information direction. When a filter drops a lot of content, the full
output is teed to disk first (src/core/tee.rs: outputs under 500 bytes skip the escrow, files
rotate at 20, each capped at 1 MiB). The grep filter shows both the cut and the receipt. Generate
120 matching lines:
python3 -c "
lines = ['def handler_%03d(): return parse(payload_%03d)' % (i,i) for i in range(120)]
open('handlers.py','w').write('\n'.join(lines)+'\n')"
rtk grep parse handlers.py
120 matches in 1 files:
1:def handler_000(): return parse(payload_000)
2:def handler_001(): return parse(payload_001)
3:def handler_002(): return parse(payload_002)
4:def handler_003(): return parse(payload_003)
5:def handler_004(): return parse(payload_004)
6:def handler_005(): return parse(payload_005)
7:def handler_006(): return parse(payload_006)
8:def handler_007(): return parse(payload_007)
9:def handler_008(): return parse(payload_008)
10:def handler_009(): return parse(payload_009)
11:def handler_010(): return parse(payload_010)
12:def handler_011(): return parse(payload_011)
13:def handler_012(): return parse(payload_012)
14:def handler_013(): return parse(payload_013)
15:def handler_014(): return parse(payload_014)
16:def handler_015(): return parse(payload_015)
17:def handler_016(): return parse(payload_016)
18:def handler_017(): return parse(payload_017)
19:def handler_018(): return parse(payload_018)
20:def handler_019(): return parse(payload_019)
21:def handler_020(): return parse(payload_020)
22:def handler_021(): return parse(payload_021)
23:def handler_022(): return parse(payload_022)
24:def handler_023(): return parse(payload_023)
25:def handler_024(): return parse(payload_024)
+95 more in handlers.py [see remaining: tail -n +26 ~/Library/Application Support/rtk/tee/1783049536_grep_0_handlers_py.log]
The last line is the design insight: after truncating, the filter tells the model where the
rest lives, as a command the model can run. If the agent decides match 87 matters, it recovers
it with one cheap tail instead of re-running the search or, worse, guessing. Compression with a
receipt beats compression with amnesia. During the research for this chapter, RTK filtered one of
our own source greps mid-investigation and handed back exactly such a pointer; we followed it and
lost nothing.
The ledger: ceil(chars/4), and what the meter cannot see
Every filtered command ends with timer.track(original, rtk_cmd, &result.stdout, &filtered),
which writes one row to a SQLite database. The token arithmetic behind every number rtk gain
ever shows you is four lines:
#![allow(unused)] fn main() { // src/core/tracking.rs pub fn estimate_tokens(text: &str) -> usize { // ~4 chars per token on average (text.len() as f64 / 4.0).ceil() as usize } }
No tokenizer, no API call: ceil(chars/4), the same rule of thumb Chapter 2
introduced. The doc comment is upfront that this is a tracking approximation. The database lives at
~/Library/Application Support/rtk/history.db on macOS (~/.local/share/rtk/ on Linux), and its
schema is one table wide enough to hold the whole story:
sqlite3 -header -column ~/Library/"Application Support"/rtk/history.db \
"SELECT original_cmd, input_tokens, output_tokens, saved_tokens,
ROUND(savings_pct,1) AS pct
FROM commands ORDER BY id DESC LIMIT 2;"
original_cmd input_tokens output_tokens saved_tokens pct
------------------- ------------ ------------- ------------ ----
git log --stat -n 3 169 144 25 14.8
git log 276 228 48 17.4
Those are our two lab commands. And here is the discovery. Our raw.txt was 2,387 chars, which is
ceil(2387/4) = 597 tokens, yet the ledger recorded input_tokens = 276. The meter never saw
vanilla git log at all. Look again at run_log: timer.track receives result.stdout, the
output of git with the injected format and cap already applied. We can reproduce that
intermediate stream by running RTK's actual git invocation ourselves:
git log --pretty='format:%h %s (%ar) <%an>%n%b%n---END---' -10 --no-merges > shaped.txt
python3 -c "
import math
for name in ['raw.txt','shaped.txt','filtered.txt']:
n = len(open(name,'rb').read())
print(f'{name}: {n} chars -> ceil(n/4) = {math.ceil(n/4)} tokens')
"
raw.txt: 2387 chars -> ceil(n/4) = 597 tokens
shaped.txt: 1104 chars -> ceil(n/4) = 276 tokens
filtered.txt: 912 chars -> ceil(n/4) = 228 tokens
The chain reconciles to the exact token: 276 is the ledger's input_tokens, 228 its
output_tokens. The pipeline was 597 → 276 → 228, but the meter only watches the second arrow.
Stage 1's 321-token cut, the majority of the whole saving, is invisible to RTK's own analytics.
The true end-to-end reduction on this command is 62 percent; the ledger says 17.4.
So the meter is honest about what it measures and blind to what it prevents, and the blindness
points in the conservative direction: whatever rtk gain claims, the input-shaping wins come on
top. This is the mirror image of Chapter 27's Headroom lesson. There, a
98 percent ratio overstated usefulness because nobody inspected the loss. Here, a 17 percent ratio
understates it because the measurement starts after the biggest lever has fired. Both are the same
moral: a meter is a claim about a pipeline stage, not about the pipeline, and you cannot know
which stage without reading the code or testing the boundary yourself.
Don't be confused. The ledger's
input_tokensand the API usage block'sinput_tokens(Chapter 23) share a name and nothing else. RTK's is the estimated size of the command output entering the filter; the API's counts everything entering the model. The two meet only in the sense that whatever RTK emits eventually becomes part of the API's input count on the next turn.
How a row is born
The gathering mechanics are worth spelling out, because every number in every report descends
from one call at the end of every filter. Each filter starts a stopwatch
(tracking::TimedExecution::start(), which just records an Instant) before running the
underlying command, and ends with the timer.track(...) call we saw in run_log. That lands in
Tracker::record, which computes the whole row locally:
#![allow(unused)] fn main() { // src/core/tracking.rs, Tracker::record() let saved = input_tokens.saturating_sub(output_tokens); let pct = if input_tokens > 0 { (saved as f64 / input_tokens as f64) * 100.0 } else { 0.0 }; let project_path = current_project_path_string(); // added: record cwd self.conn.execute( "INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", ... }
Nine columns, all computed at insert time: an RFC 3339 UTC timestamp, the original and rewritten
command strings, the canonicalized working directory (current_project_path_string() is just
std::env::current_dir() canonicalized), the two token estimates, their difference, the
percentage, and the stopwatch's milliseconds. exec_time_ms is what rtk gain averages into its
"avg 7ms" line, and it answers the reasonable worry that a filtering proxy adds latency: the
stopwatch wraps the whole execution, underlying command included, so the filter overhead is
bounded by it. Note what is absent: no raw output text, no filtered text, no file paths beyond
the cwd. The ledger stores measurements, not content, which is why it is safe to leave lying
around in your home directory.
Failures get their own quieter channel. If a filter's parser chokes on unexpected output, RTK
falls back to passing the raw output through (so the agent is never blocked), and logs the event
to a second table via record_parse_failure_silent, a function whose entire error handling is
"never crash over bookkeeping":
#![allow(unused)] fn main() { // src/core/tracking.rs conn.execute( "CREATE TABLE IF NOT EXISTS parse_failures ( id INTEGER PRIMARY KEY, timestamp TEXT NOT NULL, raw_command TEXT NOT NULL, error_message TEXT NOT NULL, fallback_succeeded INTEGER NOT NULL DEFAULT 0 )", [], )?; }
Where the ledger lives, and when it dies
Storage is a single SQLite file named by HISTORY_DB in src/core/constants.rs, placed under
the platform data directory: ~/Library/Application Support/rtk/history.db on macOS,
~/.local/share/rtk/history.db on Linux, %APPDATA%\rtk\history.db on Windows. Rows are not
kept forever. Every insert ends with a cleanup_old() sweep that deletes anything older than
DEFAULT_HISTORY_DAYS = 90 from both tables, so the ledger is a rolling three-month window that
maintains itself: no cron job, no vacuum ritual, the write path is the retention policy.
From rows to the dashboard
rtk gain is then just SQL over that one table. The summary is a SUM and a ratio (note that
the "percent saved" it prints is SUM(saved)/SUM(input), a token-weighted average, not the
mean of per-command percentages, so one huge diff dominates a hundred tiny ls calls). The
per-command table is a GROUP BY on the command string, and the project scoping uses that
project_path column: rtk gain alone reports global scope, while inside a repo it can filter
rows to the current directory subtree (the query matches exact path or path/* with SQL GLOB,
avoiding LIKE because _ and % are legal in file paths and would act as wildcards). Live,
mid-chapter, on this machine:
rtk gain --history | head -9
RTK Token Savings (Global Scope)
════════════════════════════════════════════════════════════
Total commands: 146
Input tokens: 651.3K
Output tokens: 404.9K
Tokens saved: 246.3K (37.8%)
Total exec time: 24.8s (avg 170ms)
Efficiency meter: █████████░░░░░░░░░░░░░░░ 37.8%
Those 146 commands include this chapter's own lab runs; the instrument measures itself being
used. And now you can read every field with source-level precision: "Input tokens" is
SUM(input_tokens), the post-injection stdout estimates, ceil(chars/4) each; "saved" is the
stage-2 delta only; and the whole thing forgets rows older than 90 days.
The long tail: 63 filters in TOML
The hand-written Rust filters cover the high-traffic commands (git alone is a 3,400-line file,
and gh, cargo, pytest, docker, and friends get the same treatment). The long tail is
declarative: v0.43.0 ships 63 filters as TOML files under src/filters/, compiled into the
binary and interpreted by a generic engine (src/core/toml_filter.rs). Here is df.toml,
complete with its regression tests:
[filters.df]
description = "Compact df output — truncate wide columns, limit rows"
match_command = "^df(\\s|$)"
strip_ansi = true
truncate_lines_at = 80
max_lines = 20
[[tests.df]]
name = "short output passes through unchanged"
input = "Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/sda1 4096000 123456 3972544 4% /"
expected = "Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/sda1 4096000 123456 3972544 4% /"
A filter is a match pattern plus a pipeline of primitives (strip ANSI, truncate lines, cap lines,
strip or keep by pattern), with test cases riding in the same file. The engine also reads a user
file at ~/Library/Application Support/rtk/filters.toml, so extending RTK to a tool it has never
heard of requires no Rust at all: the marginal cost of covering one more command is a dozen lines
of configuration. That economy is why the registry can afford 82 entries.
What to steal for your own filters
Strip away the Rust and RTK is five design rules. They apply to anything you put between a command and a context window, including a 30-line wrapper script.
- Sit at the choke point, not beside it. Rewriting the tool call inside the harness is what makes consumption structural rather than hopeful. A tool the model must remember to invoke saves tokens only when the model remembers (Chapter 20 measured how often that fails).
- Ask for less before you delete. The flag lever (
--pretty,-n,--porcelain) is free and reversible; the deletion lever is lossy. Pull them in that order, and defer whenever the caller already chose. - Guard the floor. A
never_worsecomparison costs one length check and converts your worst case from "made things worse" to "did nothing." - Escrow, don't destroy. Drop bytes from the context, not from the world, and tell the model where the rest lives so recovery costs one command, not one re-run.
- Know what your meter measures. RTK's ledger is exact about stage 2 and blind to stage 1. That is fine, because it errs conservative and the source says so. Whatever you instrument, write down which arrow of your pipeline the number describes, or your future self will quote it for the wrong one.
And one rule about trust rather than tokens: refuse what you cannot attest. The unattestable list (substitutions, redirects, heredocs) is RTK deciding that correctness outranks savings. A filter that rewrites everything eventually rewrites something whose behavior it changed.
Takeaways
- The consumption guarantee is structural: a PreToolUse hook rewrites the model's own tool call, and the rewritten command's stdout is the tool result the stateless API loop resends every turn. Nothing is detected; there is nowhere else for the bytes to go.
- The hook is narrow and polite: 1 MiB stdin cap, exact tool-name match, silence for anything it
will not touch, and
permissionDecision: "allow"only when the user's own rules already allow the command. - Filtering happens twice: argument injection before the command runs (format strings, caps,
--no-merges) and block-level post-filtering after (80-column headers, 3-line bodies, trailer stripping). On the lab repo the pair cutgit logfrom 597 tokens to 228. - Two safety nets make the filtering trustworthy:
never_worseguarantees the filter never emits more than raw, and the tee escrow keeps dropped bytes on disk behind a pointer the model can follow. - The ledger is
ceil(chars/4)into SQLite, and it measures only the post-filter stage: the ledger said 17.4 percent on a command whose true cut was 62. Meters describe pipeline stages, not pipelines; this one at least errs conservative. - Stats are gathered by one
timer.track()call per filtered command, stored as nine computed columns (no content, just measurements plus the cwd for project scoping) inhistory.dbunder the platform data dir, self-pruned to a 90-day window on every insert, with parse failures logged to their own table.rtk gainis plain SQL over that file, and its percentage is token-weighted, not a mean of per-command ratios. - 82 rewrite families, 63 of them declarative TOML with embedded tests, plus a user extension file: the marginal filter costs configuration, not code.
👉 That is the microscope pass: one tool, opened to the byte level, and five rules worth carrying out of it. But five rules extracted from one specimen might just be that specimen's habits. Next we put a second, independently written filter under the same lens, snip, and find out which parts of the anatomy are RTK and which parts are the species.