Configuration reference
trimwire install writes a commented starter config and turns the workhorse
strategies on. This file documents every knob. You rarely need most of them.
Where config lives (two-tier + env)
Section titled “Where config lives (two-tier + env)”Resolved in this order, later wins:
- Global:
$XDG_CONFIG_HOME/trimwire.toml, falling back to~/.config/trimwire.toml. - Per-project:
./.trimwire.tomlin the working directory (overrides global; lists are replaced, not merged). - Environment:
TRIMWIRE_*variables, nested with__. E.g.TRIMWIRE_SERVER__LISTEN=127.0.0.1:9000,TRIMWIRE_STRATEGIES__BLOAT_CAP__THRESHOLD_BYTES=32768.
Edit the global config quickly with trimwire config.
Per-session control (one Claude Code on, another off)
Section titled “Per-session control (one Claude Code on, another off)”trimwire routes Claude Code through the gateway via the ANTHROPIC_BASE_URL env
var that trimwire install adds to your shell rc. Activation is therefore
per-shell, so running it for one Claude Code session but not another already
works — no global toggle needed:
- Bypass trimwire for one shell (gateway stays up for everyone else):
unset ANTHROPIC_BASE_URLthen runclaude— it goes straight to Anthropic. - Bypass for a single command:
ANTHROPIC_BASE_URL= claude …. - Use trimwire for ONE session without the always-on service:
trimwire run …— it scopesANTHROPIC_BASE_URLto just that child process (reusing the running gateway if one is up, else starting a private one) and tears it down afterward.
So “active for this session, off for that one” is just which shells export
ANTHROPIC_BASE_URL. (Note: trimwire off stops the gateway globally and does
not edit your rc — see its output for how to send a shell straight to Anthropic.)
profile — the one knob most people need
Section titled “profile — the one knob most people need”profile = "default" # "default" | "gentle"A profile seeds every strategy knob below it, so you usually don’t touch the
[strategies.*] tables at all. It sits below your explicit keys in the merge
order; anything you set by hand still wins.
| profile | strategies | who it’s for |
|---|---|---|
default (shipped) | All eight cache-safe strategies with aggressive knobs: mostly keep_recent_turns=2 (a few wider — stale_reads/thinking_strip keep 4), bloat cap 4 KB, image keep 1, verb-class denylist for browser tools, plus stale_input_cap, stale_reads, thinking_strip, and reprune on. (Exact per-strategy values are in the sections below.) | Most people; especially long or tool-heavy sessions. Cleanest context, kept cache-stable by reprune. |
gentle | cross_turn_dedup + failed_input_purge + conservative bloat_cap (32 KB / keep 6) + thinking_strip (keep 8) + reprune. stale_input_cap, stale_reads, sliding_window, and image_strip are off. | Lightest-touch option; least pruning, least rot protection. |
Both profiles turn on stable-prefix re-pruning ([reprune]), which keeps the
pruned prefix byte-identical between checkpoints so the prompt cache survives.
That’s what makes the aggressive default cache-safe. Cost behaviour across
profiles is non-monotonic (more pruning is not always lower cost); see
benchmark/ for the numbers. An unrecognised name falls back to default with
a warning. Setting profile in a project ./.trimwire.toml is allowed (it only
affects pruning; upstream stays global-only).
[server]
Section titled “[server]”[server]listen = "127.0.0.1:8765" # numeric IP:port the gateway bindsupstream = "https://api.anthropic.com" # where slimmed requests are forwardedlisten must be a numeric IP:port (e.g. 127.0.0.1:8765 or [::1]:8765),
not a hostname — localhost:8765 is rejected (it falls back to the default with
a warning). If you change listen, set ANTHROPIC_BASE_URL to match.
Strategies
Section titled “Strategies”They run in this fixed order: failed_input_purge → stale_input_cap →
cross_turn_dedup → stale_reads → simhash_dedup → bloat_cap →
sliding_window → image_strip → thinking_strip. Each has an enabled
flag. simhash_dedup is the only one OFF in both shipped profiles (opt-in);
thinking_strip is ON in both (its struct default is off, but the profiles
enable it). Tool-name lists support * globs. trimwire stats shows which
fire and how much each saves.
[strategies.cross_turn_dedup] — workhorse, on by default
Section titled “[strategies.cross_turn_dedup] — workhorse, on by default”Same tool called again with identical arguments (re-reading a file, re-running a grep) → keep only the most recent result; replace earlier identical ones with a marker. Safe: only provably-superseded duplicates are removed.
[strategies.cross_turn_dedup]enabled = true# exempt_tools = ["Task", "Agent"] # tools never deduped (default: subagent tools)# stub = "[trimwire: superseded by a later identical call]"[strategies.failed_input_purge] — workhorse, on by default
Section titled “[strategies.failed_input_purge] — workhorse, on by default”For tool calls whose result errored, reduce the (often large) input after
a few turns: keep the small scalar fields (command, file_path, flags up to
~512 B) so the model still knows which call failed, and replace only the bulk
(large strings and nested arrays/objects: heredocs, file bodies, stdin) with
a content-free size marker. A small input is left untouched. The tool name, id,
and the error text are always preserved.
[strategies.failed_input_purge]enabled = truekeep_recent_turns = 4 # struct default; the `default` profile sets this to 2# exempt_tools = [][strategies.bloat_cap] — on by default
Section titled “[strategies.bloat_cap] — on by default”Trim a single oversized tool_result (e.g. a huge build log) to
head + tail + a [trimwire: trimmed N bytes] marker, but only once it’s older
than keep_recent_turns. Skipped if the trim wouldn’t actually shrink it.
Never touches file-editing tools. For array-content results (e.g. multi-block
MCP output) it salvages the bulky text blocks in place (same head/tail trim),
keeping structure, small blocks, and images; a pure non-text/image array over
threshold is replaced by a single size marker.
[strategies.bloat_cap]enabled = truethreshold_bytes = 16384 # struct default; the `default` profile sets this to 4096head_bytes = 2048 # bytes kept from the starttail_bytes = 2048 # bytes kept from the endkeep_recent_turns = 4 # struct default; the `default` profile sets this to 2exempt_tools = ["Edit", "Write", "MultiEdit", "Task", "Agent"] # never trimmed at ANY age (authoring + subagent; Task and Agent are both subagent-launch names)exempt_recent_only_tools = ["Read"] # exempt only while RECENT; an OLD large Read IS trimmed (the "Read coverage gap" fix)# --- opt-in levers (all default 0 / empty = OFF; zero behaviour change unless set) ---catastrophic_bytes = 0 # if >0, also caps a RECENT result this large (it can't # fit the context window anyway, so it would brick the # session). Set it WELL ABOVE threshold_bytes. Uses a # generous head/tail floor and a distinct marker.stub_age_turns = 0 # if > keep_recent_turns, results older than this are # FULLY stubbed (not head+tail). Must exceed # keep_recent_turns or it has no effect. String content # only; trades old-result fidelity for size.protected_file_patterns = [] # globs of file paths never trimmed (mirror the same # globs in [strategies.stale_reads] for full protection)[strategies.sliding_window] — most aggressive, browser-only by default
Section titled “[strategies.sliding_window] — most aggressive, browser-only by default”Stub denylisted tool_use/tool_result pairs older than keep_recent_turns,
regardless of whether they’re duplicates. More aggressive than dedup, so it
defaults to browser-automation tools only. Add e.g. "Bash"/"Grep" only if
you want all their old results dropped (this can remove a unique result the
model still wanted; dedup is the safer general win).
[strategies.sliding_window]enabled = truekeep_recent_turns = 4 # struct default; the `default` profile sets this to 2# the shipped `default` value — verb-class globs matching browser automation# (`*browser_act*`, NOT a bare `*act*`, which would also match extract/interact/redact):denylist_tools = ["*screenshot*", "*navigate*", "*click*", "*browser_act*", "Grep"]exempt_tools = ["Read", "Edit", "Write", "MultiEdit", "Task", "Agent"] # never stubbed, even if denylisted# stub = "[trimwire: elided, older than sliding window]"[strategies.image_strip] — on by default
Section titled “[strategies.image_strip] — on by default”Replace base64 image payloads (e.g. screenshots) older than the K most-recent matching results with a marker.
[strategies.image_strip]enabled = trueapplies_to_tools = ["*screenshot*"]keep_recent_count = 3 # struct default; the `default` profile sets this to 1# stub = "[trimwire: image stripped]"[strategies.stale_input_cap] — on in default, off in gentle
Section titled “[strategies.stale_input_cap] — on in default, off in gentle”Caps the bulky input of an old successful tool call (the failed-input
counterpart is failed_input_purge). Protects the most recent turns.
[strategies.stale_input_cap]enabled = truekeep_recent_turns = 2 # turns protected from capping# shipped default — authoring/sub-agent tools are never capped (Write/Edit/MultiEdit/# NotebookEdit are ALSO a hard floor; Task and Agent are only protected via this list):exempt_tools = ["Task", "Agent", "Write", "Edit", "MultiEdit", "NotebookEdit"] # tool names (globs) never capped[strategies.stale_reads] — on in default, off in gentle
Section titled “[strategies.stale_reads] — on in default, off in gentle”Elides an old Read whose file was later superseded (re-read / Write / Edit on
the same path), and demand-pages the last read of a path once it exceeds
page_min_bytes. Never breaks a tool pair.
Hot-path guard: a path the model has Read more than once in the session
is never demand-paged. Paging the current view of a file the model keeps needing
would just force another re-read (paged out again next turn) — a read-spiral. So
demand-paging only pages genuinely one-shot large reads; the current view of a
file you keep re-reading is left verbatim (only demand-paging is suppressed —
superseded earlier reads of that path are still elided as usual).
[strategies.stale_reads]enabled = truekeep_recent_turns = 4page_min_bytes = 16384 # default profile: only page reads larger than this (16 KB; lowered from 32 KB in v0.3.0). struct default 0 = offexempt_tools = []protected_file_patterns = [] # opt-in (default empty = off): globs of file paths that # are never superseded-elided OR demand-paged. Mirror the # same globs in [strategies.bloat_cap] for full protection.# stub = "[trimwire: stale read…]"[strategies.thinking_strip] — on in BOTH profiles (struct default off)
Section titled “[strategies.thinking_strip] — on in BOTH profiles (struct default off)”Drops old thinking / redacted_thinking blocks (reasoning for
already-solved problems). Removals are replayed by reprune by signature, so the
cache still holds. default keeps the last 4 turns of reasoning, gentle the
last 8.
[strategies.thinking_strip]enabled = truekeep_recent_turns = 4[strategies.simhash_dedup] — OPT-IN (off in both profiles)
Section titled “[strategies.simhash_dedup] — OPT-IN (off in both profiles)”Catches near-duplicate tool_results that cross_turn_dedup (exact-match)
misses, using a SimHash within hamming_threshold bits. Off by default; enable
it if you have very repetitive tool output.
[strategies.simhash_dedup]enabled = falsekeep_recent_turns = 4 # struct default (simhash is opt-in; no profile sets it)hamming_threshold = 3 # ≤ this many differing bits (of 64) = duplicatemin_bytes = 512 # ignore results smaller than thisexempt_tools = []# stub = "[trimwire: near-duplicate…]"[strategies.system_shape_normalize] — OPT-IN (off by default)
Section titled “[strategies.system_shape_normalize] — OPT-IN (off by default)”Lifts a stray messages[0] whose role is "system" up into the top-level
system field. Some flows (after /compact, /clear, or a mid-session model
switch) can emit a leading system-role message, which the API rejects with a 400.
Off by default; enable it only if you actually hit that 400 (it mutates request
shape, so it stays opt-in to keep the default path transparent).
[strategies.system_shape_normalize]enabled = false[reprune] — stable-prefix re-pruning (on in both shipped profiles)
Section titled “[reprune] — stable-prefix re-pruning (on in both shipped profiles)”[reprune]enabled = true # on in both shipped profiles; set false to disablethreshold = 8 # re-prune cadence: new messages before a full re-prune (~2× keep_recent)recheckpoint_result_bytes = 131072 # default profile (128 KB); 0 = off (gentle + struct default). # Forces a re-checkpoint when the tail carries > this many bytes of # tool_result content, with no-advance-on-no-op rollback (the # byte-based re-checkpoint from the "Read coverage gap" fix).max_sessions = 1024ttl_secs = 3600By default trimwire re-prunes every request from scratch; as messages age out of
the recent window, the pruned prefix shifts turn-to-turn and busts Anthropic’s
prompt cache. With reprune on, trimwire keeps per-session state and, while the
conversation is append-only, re-uses the previous turn’s pruning decisions so
the pruned prefix stays byte-identical. It recomputes only once the tail grows
past threshold messages, or when history is rewritten (compaction). This cuts
prompt-cache churn and cost on long / churn-heavy sessions (offline-measured
−30–40%), at the cost of trimming the most-recent batch one checkpoint later.
Both shipped profiles (default and gentle) turn reprune on. That’s what keeps
the aggressive default cache-stable. Set [reprune] enabled = false to override.
It can never produce wrong output: any prefix change forces a full re-prune
identical to the stateless path (worst case is a cache miss). State is bounded by
max_sessions (LRU) and ttl_secs (idle eviction).
[summarizer] — optional model summary of OLD content (off by default)
Section titled “[summarizer] — optional model summary of OLD content (off by default)”Start with the wizard: trimwire summarizer setup writes this block for you.
The knobs below are for hand-tuning ~/.config/trimwire.toml afterward.
Opt-in. When engine is not "model-free" (and [reprune] is on), trimwire sends
the OLD prunable slice to a local ollama model or your own API key and replays
the resulting summary in place of that slice — a clean summary the model can read
instead of lossy elision markers. Requires reprune (it carries the summary across
turns). See SUMMARIZER.md for the full guide; this is the
knob reference. ToS: trimwire never originates calls on your Claude subscription
token — only on a local model or an API key you provide.
[summarizer]engine = "model-free" # "model-free" (off) | "local" | a provider id belowfallback = [] # ordered engine ids to try if the primary failsmode = "default" # "default" | "gentle" (a slower, more conservative cadence)trigger_bytes = 204800 # only engage once the request body exceeds this (200 KB)timeout_secs = 180 # hard timeout per summarizer callkeep_recent_turns = 6 # recent assistant turns never summarized (working set)resummarize_after_bytes = 32768 # re-summarize once this much new old-content accruesaccumulator = true # append frozen delta segments instead of replacingmax_summary_segments = 128 # frozen-segment cap (raise for very long sessions; cache-safe)# slice_char_budget = 131072 # max serialized bytes summarized per segment. Unset =# # per-engine default: local ~60 KB (num_ctx/OOM-safe, from# # the 25600 default below), API-only chain ~128 KB.# # Bigger = the summary owns more old content (API engines).accept_ratio = 1.0 # 1.0 = strict (keep summary only if smaller than model-# # free). >1.0 (e.g. 1.5, for strong API engines) keeps a# # higher-fidelity summary up to ratio× the model-free size# # (capped at +16 KB). Keep 1.0 for weak local models.
[summarizer.local] # used when engine/fallback includes "local"endpoint = "http://localhost:11434"model = "qwen3.5:4b" # an approved local tag; weaker tags are warned/refusedmax_num_ctx = 25600 # ollama num_ctx + local slice budget (≈max_num_ctx×2.5−2000# # chars ≈ 60 KB). KEEP THE DEFAULT for qwen3.5:4b: raising to# # 40000 (≈96 KB) FAILS it (N=10: 0/10, drops the same ~2 facts# # every run) — see MODEL-COMPATIBILITY.md. Only raise on a model# # you've gated at the larger size with `summarizer probe`.# # Clamped at 131072.keep_alive_secs = 0 # 0 = unload the model from RAM after each summarizer call# # (RAM-saving default). Raise (e.g. 60) to keep it warm# # between calls on a GPU/high-RAM box.
# [[summarizer.providers]] # one block per cloud provider; reference by `id`# id = "myapi"# style = "anthropic" # "anthropic" | "openai" (auth header + payload shape)# base_url = "https://api.example.com" # API root; style path /v1/... is appended# # (no trailing /v1 for openai)# full_url = "..." # OPTIONAL exact POST URL — bypasses base_url + /v1 for# # non-standard paths (Z.ai /paas/v4, Azure deployments).# # When set, base_url is ignored; style still applies.# model = "claude-haiku-4-5"# api_key_env = "MYAPI_KEY" # env var holding the key (never inline the key)See SUMMARIZER.md “Provider recipes” for copy-paste configs (OpenAI, OpenRouter, Anthropic, Z.ai anthropic + openai, Azure, self-hosted vLLM).
To let the summary own a large fraction of old content on a strong cloud model, use an
API-only chain (no "local" in engine/fallback) with accept_ratio = 1.5; the
128 KB API budget then covers far more per segment (raise slice_char_budget for more,
mindful of the provider’s context window). The deterministic strategies still run and
remain the full fallback when the summarizer is off or fails.
[ledger]
Section titled “[ledger]”Per-request savings + cache-prefix telemetry (byte counts and hashes only, no
message content). Pruned to retain_days at startup. Set enabled = false to
record nothing.
[ledger]enabled = truedb_path = "~/.trimwire/ledger.db"retain_days = 365Note db_path defaults to ~/.trimwire/, not an XDG_DATA_HOME location —
so setting XDG_DATA_HOME alone does not redirect the ledger. To point the
ledger somewhere else (e.g. to isolate a test run or manual canary from your real
data), set the env override TRIMWIRE_LEDGER__DB_PATH=/path/to/ledger.db.
[share] — opt-in anonymous telemetry (off by default)
Section titled “[share] — opt-in anonymous telemetry (off by default)”Only used by trimwire share stats and trimwire share benchmark.
Opt in with trimwire share enable (persists enabled = true); opt out with
trimwire share disable. Once enabled, trimwire share stats uploads without
--yes each run. --yes also works as a per-run override or first-time
confirmation. --force bypasses the once-per-day throttle.
The built-in community stats collector URL ships in the binary and points at
https://api.trimwire.dev/ingest. [share] endpoint exists as an override for
self-hosting or testing; it is not the normal path. The benchmark collector
ships the same way (built-in URL https://api.trimwire.dev/ingest-benchmark,
overridable via [share] benchmark_endpoint); trimwire share benchmark uploads
only with --yes, otherwise it dry-runs.
The payload is coarse, bucketed, and anonymized client-side; it never contains
prompts, code, paths, ids, IPs, or raw counts. See
TELEMETRY.md for the full field-by-field contract.
[share]enabled = false # set true via `trimwire share enable` to persist consentendpoint = "" # override for self-hosting/testing only; leave empty for the community collectorbenchmark_endpoint = "" # same, for `trimwire share benchmark`Seeing your savings
Section titled “Seeing your savings”trimwire stats always works (reads the ledger; no setup). For an in-session
view there are two pieces:
Live bar (the statusline) — opt-in
Section titled “Live bar (the statusline) — opt-in”trimwire install never touches your statusline. Adding the bar is a
separate, explicit step (Claude Code’s statusline is a single command with no
“field” API, so the bar can only live in statusLine):
trimwire statusline add # if you have NO statusline → makes trimwire your bartrimwire statusline wrap # if you ALREADY have one → adds a trimwire row beneath ittrimwire statusline remove # undo either (restores a wrapped original exactly)addwritesstatusLine → trimwire statusline renderto~/.claude/settings.json(atomic write; refuses if the file has JSON comments it can’t parse). If a statusline already exists it leaves it untouched and points you towrap.wrapmakes trimwire thestatusLine, runs your original bar (feeding it the same input), and prints a trimwire row beneath it. Your original command is stashed in~/.trimwire/statusline-wrapped.cmd;remove(anduninstall) restore it exactly.- (
trimwire statusline renderis the command those write into settings.json; Claude Code runs it. You never type it, so it’s hidden from--help.)
The bar prints ⊡ trimwire 128 KB (~32K tok) · 41% ↓ · 87 reqs for the current
session (· ready before any traffic; a yellow ⚠ not responding when it’s
set-but-down). It’s intentionally terser than an in-session AI assistant block (no
“messages/tools compressed” or topic; trimwire is a byte-level proxy). The full
per-strategy breakdown is in trimwire stats.
Heads-up on wrapping: Claude Code runs only one
statusLine, and the last tool to write that key wins it. Ifclaude-statuslineis reinstalled after you wrap, it’ll overwrite the key and trimwire’s row disappears. Just re-runtrimwire statusline wrap.
Manual integration into your own statusline script
Section titled “Manual integration into your own statusline script”If you’d rather embed trimwire inside your own script instead of wrapping (e.g.
claude-statusline, a single
shell script with no plugin hook), add trimwire as a segment. Both read
Claude Code’s JSON from stdin, and stdin can only be consumed once, so reuse
the $input your script already captured; don’t re-read stdin:
# in statusline.sh, right after the existing `input=$(cat)` line:tw_seg=$(printf '%s' "$input" | trimwire statusline render 2>/dev/null)
# then add "$tw_seg" to a row's join_segs call, e.g.:row2_right=$(join_segs "$cost_seg" "$tw_seg")join_segs skips empty segments, so when the ledger is empty or trimwire is
off the segment just vanishes. (If your justify helper isn’t ANSI-aware, the
colored segment may offset right-alignment. Drop the color by piping through
sed 's/\x1b\[[0-9;]*m//g' if so.)
Health alert hook (works without any statusline)
Section titled “Health alert hook (works without any statusline)”Wire trimwire hook as a SessionStart (and/or UserPromptSubmit) hook. It
stays silent when healthy and emits a visible systemMessage only when trimwire
is configured but not serving, so a silent “set but not pruning, paying full
price” failure can’t go unnoticed even if you don’t watch the statusline:
{ "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "trimwire hook" } ] } ] } }Runtime environment variables (not config-file keys)
Section titled “Runtime environment variables (not config-file keys)”These tune the running daemon, separate from the config layers above:
TRIMWIRE_AUDIT=<file>: opt-in wire audit. When set, the daemon appends one JSONL line per/v1/messagesdescribing the shape of each request (counts, sizes, flags, the model, theanthropic-betaheader, and the session id) and never any message content, tool input, or result text. It also records the cache-prefix structure (structural identifiers only): the ordered tool-definition names (tool_names), whichtools/systemblocks carry acache_controlbreakpoint, thesystemshape, and themessages[0]block-type sequence (first_msg_blocks) — used to investigate prompt-cache behaviour. Off by default (no cost when unset). Useful to see exactly what Claude Code sends on the wire and what trimwire forwards. Also available as--audit <file>ontrimwire serve/trimwire run.TRIMWIRE_LOG=info|debug|warn: gateway log verbosity (tracingfilter).
Tuning notes
Section titled “Tuning notes”The shipped defaults (threshold_bytes, keep_recent_turns, the
sliding_window denylist) are conservative starting points, not
telemetry-tuned values. Run a few real sessions, check trimwire stats, and
adjust. Per-profile savings/cost numbers and the methodology are in
benchmark/ (start with its headline tables).