How the Furl context-compression plugin works in Claude Code and Codex — the furl_compress / furl_retrieve / furl_stats / furl_purge / furl_search / furl_list MCP tools, the <<ccr:HASH>> retrieval flow, and Claude Code's optional automatic hooks. Use when the user asks what Furl is doing, how to compress or retrieve content, why Claude Code output looks compressed, or how to tune or disable Furl.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add omar-y-abdi/furl-ctx --skill furl --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Furl?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/omar-y-abdi-furl)More formats (shields.io, HTML) on the badges page.
---
name: furl
description: How the Furl context-compression plugin works in Claude Code and Codex — the furl_compress / furl_retrieve / furl_stats / furl_purge / furl_search / furl_list MCP tools, the <<ccr:HASH>> retrieval flow, and Claude Code's optional automatic hooks. Use when the user asks what Furl is doing, how to compress or retrieve content, why Claude Code output looks compressed, or how to tune or disable Furl.
version: 1.4.0
---
# Furl — context compression for Claude Code and Codex
Furl reduces the tokens large tool outputs cost by compressing them, while keeping
every dropped byte **retrievable on demand**. It ships these capabilities:
1. An **MCP server** (`furl`) exposing six tools in Claude Code and Codex.
2. A retrieval-aware **skill** in both hosts.
3. **Automatic hooks in Claude Code only.** Codex uses explicit `furl_compress` calls;
its separate package root intentionally excludes Claude's incompatible hooks.
**Retrieval is pull-based, not push-based.** The compressed text you read does not
contain the dropped rows. To inspect a specific dropped item, call `furl_retrieve` for
it by pattern, field, or line range. Retrieval is byte-exact for raw text. A JSON
array narrowed by `select`/`fields` comes back re-serialized — semantically complete
but pretty-printed, so **not** byte-identical to the source; when byte fidelity
matters (hashing, diffing, signature checks) pass `raw: true` on a row-select and
each matched row returns byte-identical to its source bytes, the rows rejoined with
fresh array punctuation, so each row is exact while the whole blob is not one
contiguous slice.
**What actually decides byte-exactness is the offload route, not text-vs-JSON.** Content the
router crushes structurally (columnar and tabular arrays) is stored as canonical `json.dumps`,
so a full `furl_retrieve` returns canonical bytes — byte-identical only when your input was
already canonical. Content offloaded whole-blob (plain text, or JSON too irregular to crush) is
stored verbatim and returns byte-exact. One consequence on the crush path: the hash keys the
*canonical* form, so two byte-different inputs that canonicalize the same collapse to one stored
entry — on the crush path the hash is a semantic identifier, on the whole-blob path a byte one.
Nothing is lost, but a one-off anomaly
buried in otherwise-repetitive data will not appear in the compressed
view unless you query for it. Trust a compressed summary for the shape of the data, not
for surfacing an anomaly you were not already looking for.
## Current harness status
**Codex:** call `furl_compress` explicitly. Prefer its `file_path` input for a large
artifact so only compressed content enters context. The Codex plugin installs the MCP
server and this skill, not automatic hooks.
**Claude Code:** automatic, hands-off compression works on Claude Code 2.1.163 and newer. The PostToolUse hook emits `updatedToolOutput` mirrored to the originating tool's output shape; Claude Code 2.1.163 and newer validate that replacement against the tool's schema, and the mirrored shape passes, so the compressed output reaches the model. Both external audits confirmed this live on 2.1.212. Shape-mirroring was built for [anthropics/claude-code#68951](https://github.com/anthropics/claude-code/issues/68951), where an earlier bare-string replacement was dropped on a schema mismatch. `WebSearch` still passes through uncompressed, because its whole-object result has no single text field to mirror onto. Unaffected on every version: the manual MCP tools furl_compress, furl_retrieve, and the rest, and durable `<<ccr:HASH>>` storage and retrieval. Below 2.1.163 the SessionStart status line and the first-run note say so directly instead of claiming PostToolUse compression is active, naming the detected version; the PreToolUse pipe (below) is unaffected either way. LIBRARY.md carries the canonical harness status.
**Counters:** `furl_stats` shows `store.hook_activity.hook_invocations_seen` and `hook_compressions_applied`, cross-process and cumulative. A rising `hook_compressions_applied` confirms the hook is compressing and, on 2.1.163 and newer, delivering the shorter output the model reads; below the floor it stops incrementing instead, bucketed under `hook_noop:below-version-floor`. `store.post_tool_use_compression` reports the detected host version and whether it can receive a replacement at all, independent of the counters.
**Claude Code real savings now (enabled by default):** a **PreToolUse** pipe compresses a `Bash`
command's stdout at the source (so the tool result *is* the compressed form, original
retrievable via `furl_retrieve`) — it doesn't use `updatedToolOutput`, so it works
today. Disable it with `FURL_PRETOOL_PIPE=0` (`false`/`off`/`no`/`disabled` also work,
case-insensitively); unset, empty, or any other value leaves it on. Trade-offs:
Bash-only; the rewrite is transcript-visible (a `# furl-pipe` comment); exit code
preserved exactly; stderr is not captured and flows live, but stderr/stdout
interleaving is not preserved (all stderr precedes the compressed stdout; `2>&1`
merges); fail-open (worst case the command runs unwrapped, uncompressed); adds
~0.3–0.5 s per rewritten call (two `uv` resolves; a fresh environment pays a
one-time resolve/build on the first call). It rewrites Bash **only when there
are zero readable Bash permission rules**: if any `Bash` deny/ask/allow rule
exists in any scope Claude Code uses — enterprise managed (incl. the
`CLAUDE_CODE_MANAGED_SETTINGS_PATH` override), project (both `CLAUDE_PROJECT_DIR`
and the working dir), and user (both `~/.claude` and `CLAUDE_CONFIG_DIR`)
settings — it leaves all Bash untouched so your rules apply exactly as native, a
total boundary no command shape can bypass. Unreadable settings (or a set-but-
unresolvable config-path override) also force passthrough. Residual blindness:
CLI `--permission-mode`/`--disallowedTools` flags, SDK `managedSettings`, and
API-fetched remote org policy (`CLAUDE_CODE_REMOTE_SETTINGS_PATH`); if you
restrict Bash only through those, set `FURL_PRETOOL_PIPE=0`. Most sessions keep at
least one Bash rule, so the pipe is off by default for them; the SessionStart banner
reports the live status, and the dynamic gating reasons are tallied as `pipe_noop:<reason>`
in `furl_stats`, while the static rules-present state is shown by the banner rather than
counted per command. To compress Bash anyway, accepting that the rewrite may change how
Claude Code matches your rules (no rule-present subset is provably safe), set
`FURL_PIPE_WITH_RULES=1` — off unless explicitly set. Known limitations
(redaction gaps on fail-open paths, heredoc edge, permission-rule visibility
bounds): see the plugin README.
## The MCP tools
- `furl_compress` — compress content on demand. Pass `content` with inline text,
**or** `file_path` to have the server read and compress a file from disk — so a
large artifact (a multi-MB trace or log) that would overflow the context window
if pasted inline is ingested directly, and only the compressed form plus a
`hash` comes back. Exactly one of the two. The file path is confined to the
workspace jail and capped by `FURL_MCP_MAX_FILE_BYTES` (default 40 MiB, larger
than the inline cap). Returns compressed text plus a `hash`; the original is
stored for later retrieval. When Furl decides not to compress (a no-op: the
content is too small or would not shrink) it returns the original unchanged with
`hash: null` and stores nothing, so a no-op no longer consumes a retrieval slot;
pass `persist: true` to store it anyway and get a hash back.
- `furl_retrieve` — get original, uncompressed content back. Pass a `<<ccr:HASH>>`
marker's hash for the full original, or narrow it with a filter: `pattern` +
`context_lines` / `line_range` (regex or a line window over the text),
`fields` (project keys of a JSON array), or a **row-select** —
`select_field` + `select_equals` (a value) or `select_min`/`select_max` (a
numeric range), with optional `fields` and `limit` — to pull just the matching
ROWS of a large offloaded JSON array (or a dominant-array object like a Chrome
trace) instead of the whole thing. Add `raw: true` to a row-select for byte-exact
rows (each matched row byte-identical to its source bytes; needs `select_field`,
cannot combine with `fields`). A row-select whose `select_field` is present in NO
row returns a `select_field_absent` warning naming the field and listing the known
field names, so a typo is not silently an empty result. A free-text `query`
searches stored entries.
- `furl_stats` — session compression statistics (compressions, tokens saved, cost).
- `furl_purge` — permanently erase stored originals: one hash, or all of them. No undo.
- `furl_search` — find stored originals by a case-insensitive content substring; returns a hash + preview per hit.
- `furl_list` — list stored entries, newest first, for paging through what's been compressed this session. Each entry carries an `expires_in` — humanized time left before its retention TTL evicts it (e.g. `23h`).
A seventh tool, `furl_read`, exists but is off by default — enable with `FURL_MCP_READ=1` (see [The `furl_read` tool](#the-furl_read-tool-opt-in) below).
## When the Claude Code hook fires
The hook runs **after** a tool returns, on external-output tools: `Bash`,
`WebFetch`, `WebSearch`, `Task`. (Your own `Read`/`Grep`/`Glob` file access is left
untouched by default, so later edits still see exact file bytes.) For each result it:
1. Skips Furl's own tool traffic and anything already carrying `<<ccr:` markers
(no double-compression).
2. Skips outputs below the size threshold (default 2000 characters).
3. Compresses the rest and replaces the tool output the model sees — but **only if
compression actually made it smaller**.
It is **fail-open**: any error (compression failure, missing dependency, odd
payload) passes the original output through unchanged. It never blocks a tool call.
## How retrieval works (the `<<ccr:HASH>>` flow)
Compression is often *lossy-but-reversible* (CCR = Compressed Context Retrieval).
Instead of shrinking large low-redundancy content, Furl offloads it to a local
store and leaves a marker like `<<ccr:a1b2c3>>` in its place. `<<ccr:a1b2c3>>` is
the representative shape. The engine emits several marker shapes across two hash
widths, including bracket forms like `[N items compressed to M. Retrieve more:
hash=H]`. They all retrieve the same way: pass the hash to `furl_retrieve`.
LIBRARY.md carries the full CCR marker grammar.
When you need the full content behind a marker, **call `furl_retrieve` with that
hash** — it returns the stored original, byte-exact for raw text and a
semantically-complete re-serialization for a structured JSON array, as long as the
entry is still within its retention window. **The plugin's 24-hour default
(`FURL_CCR_TTL_SECONDS=86400`) is a ceiling, not a guarantee: the store also caps
at 1000 live entries per project. One compression counts as one entry — a columnar
row-drop stores a single whole-blob entry, not separate per-row entries, so a
structured output takes one slot, not one-plus-N. To read the dropped rows, call
bare `furl_retrieve(hash)`: it returns the complete stored array of every row.
`query=…` and `select_field` are optional narrowings that fail differently: the
relevance-scored BM25 `query=` returns at most its top matches and can report
`count: 0` for a row that is present but does not lexically match, whereas
`select_field` filters the full array and only bounds how many rows come back —
it never misses a matching row. Omit the query (or use `select_field`) when you
need the whole entry.**
Past either limit the entry is gone and a retrieve is a loud miss, never a silent
wrong answer; check `furl_stats` — its `store.cap_accounting` block reports live
entries against the cap and the `retrieval_headroom` remaining, and warns once it
reaches 90% of the cap. The hook and the `furl` MCP server share one durable
per-project SQLite store, `~/.furl/ccr-ns-<hash>.sqlite3`, keyed by
`FURL_CCR_PROJECT_DIR` so one project never sees another's entries. The global
`~/.furl/ccr.sqlite3` is used only when project scoping is turned off, and it is the
`furl` CLI default. Either way, markers the hook creates are retrievable through
`furl_retrieve`.
What a marker leaves in place depends on the offloaded **input shape** — the
columnar table is not the universal case:
- A **structured JSON array of objects** compresses to a compact columnar table
(`[N]{col:type,...}`, decoded by the MCP legend) with the full rows behind the marker.
If rows are dropped from the inline view the header becomes `[kept/total]` so the true
row count is never hidden, plus a trailing `__stats:col=min/max/sum/count` summary over
all original rows; nested-object columns (e.g. a trace's `args`) are compacted in place,
not escape-inflated.
- A **JSON object with one dominant inner array** (e.g. a Chrome trace) leaves an
`_ccr_summary` preview: schema, per-field value histograms, and numeric ranges.
This preview appears only once the object is large enough to offload — a hard
floor of **4000 characters** (~4 KB of ASCII). It counts characters, not bytes,
and is measured on the raw content you pass, before any marker is added, so
predict it from your input's own character length — a multibyte payload reaches it
with fewer bytes. A small sample (a few dozen events, under 4000 characters) comes
back unchanged with no summary, so exercise it on a realistic slice, not a 20-row toy.
- **Line-oriented text** (logs, stack traces) is *not* tabled — it leaves a head+tail
excerpt, plus any ERROR, Traceback, or other severity lines lifted from the omitted
middle so a buried error stays visible in the compressed view, with the full text
behind the marker.
**Dedup markers in a table (`_dup_count`, `<varies>`).** A kept row in the columnar
table can carry a `_dup_count: N` field: N original rows shared this row's content and
collapsed into it, so the row stands for N originals, not one. When those rows differed
in a high-cardinality identity column — a per-row id, timestamp, or counter — that
column shows the `<varies>` sentinel instead of a concrete value, because the N rows
each had a *different* one. Read `<varies>` as "N distinct values here", never as one
id or timestamp that recurred N times; the concrete per-row values are behind the
marker, so `furl_retrieve` the rows when you need them. `<varies>` is a **reserved**
sentinel: if a row's real identity value ever is literally the string `<varies>` and
is constant across its family it is kept as-is and reads the same, and either way the
exact per-row originals stay recoverable with `furl_retrieve`.
For the array and summary cases you usually want a **slice, not the whole thing**.
The summary carries a `retrieve` hint telling you which fields to filter on. Pass a
row-select to `furl_retrieve`:
`select_field=<a categorical field>, select_equals=<one of its values>` for just
those rows, or `select_field=<a numeric field>, select_min=…, select_max=…` for a
range window (add `fields=[…]` to project columns, `limit` to cap). The slice is
tiny compared to the full original, so locality and anomaly questions are
answerable without pulling megabytes back into context.
**Byte fidelity — `raw: true`.** A slice is re-serialized by default: semantically
complete but pretty-printed (2-space indent), so it is **not** byte-identical to the
stored source. That is fine for reading, wrong for hashing, diffing, or signature
checks. Add `raw: true` to a row-select and each matched row comes back
byte-identical to its exact source bytes, the rows rejoined with fresh `[ , ]`
punctuation. So each returned **row** is byte-exact — but the returned blob as a
whole is **not** one contiguous slice of the source (that would only hold when the
matched rows happen to be adjacent, so it is not the promise). `raw` needs a
`select_field` and cannot combine with `fields`: a projection returns a subset of a
row's keys, which has no source span. When the match count exceeds `limit`, a single
`{"__ccr_truncated__": …}` object is appended as the one synthetic (non-source)
element — on a namespaced key so you can strip it before hashing rather than
identify it only by position; raise `limit` to avoid truncation entirely.
**Absent-field warning.** A row-select whose `select_field` is present in **no** row
of the array comes back with a `select_field_absent` warning: it names the field and
carries the known field names in a structured `known_fields` array (sorted, sanitized,
and size-bounded, with a count of any elided). The `note` prose stays generic and never
lists the names, so an attacker-chosen key cannot ride in as instruction text, and a
mistyped field name is distinguishable from a real empty match. It fires **only**
when the field is absent from every row — **no warning means the field exists and
genuinely matched nothing**, a legitimate empty result, not a typo. A field present
in only some rows is a real field and stays silent too.
## The `furl_read` tool (opt-in)
`furl_read` is a seventh MCP tool, **off by default**. It reads a file with
session caching: the first read returns the full content and caches it, and later
reads of the *same unchanged file* return a lightweight `<<ccr:HASH>>` marker
(~20 tokens instead of the whole file) — pull the full body back with
`furl_retrieve` on the hash if you need it. Pass `fresh: true` to bypass the cache
(after a context compaction, inside a sub-agent, or whenever you need
guaranteed-current bytes).
**Why it's off by default:** it is a filesystem-reading tool. Reads are jailed to
`FURL_WORKSPACE_DIR` (and, when that is unset, to the MCP server's working
directory), and a caching layer over file reads can serve stale bytes if a file
changes out of band. Rather than silently shadow the built-in `Read` tool, Furl
ships it opt-in so enabling the extra read surface is a deliberate choice.
**How to enable:** set `FURL_MCP_READ=1` (`on`/`true`/`yes`/`enabled`) in the
plugin's `.mcp.json` env or your shell, then restart the session. Once enabled,
call it like the built-in Read:
```
furl_read(file_path="/abs/path/to/big_file.py")
```
## Tuning (environment variables)
MCP/store rows apply to both hosts. Hook, pipe, and status-line rows apply only to
Claude Code. Set values in `.mcp.json`, Claude Code's `hooks/hooks.json`, or your shell:
| Variable | Default | Effect |
|----------|---------|--------|
| `FURL_HOOK_ENABLED` | on | Set `0`/`false`/`off` to disable the hook entirely. |
| `FURL_HOOK_MIN_CHARS` | `2000` | Minimum tool-output length before the hook attempts compression. Raise to compress less, lower to compress more. |
| `FURL_HOOK_MODEL` | `claude-sonnet-4-5-20250929` | Model name used for token counting during compression. |
| `FURL_HOOK_EXCLUDE_TOOLS` | (none) | Comma-separated tool names never to compress — exact (`Bash`) or fnmatch globs (`mcp__db__*`). Furl's own tools are always excluded. |
| `FURL_HOOK_MODE` | `normal` | `aggressive` also compresses code in the blob and squeezes smaller outputs; `normal` keeps the default behavior. |
| `FURL_HOOK_VERBOSE` | off | `1`/`true` prints a one-line savings summary per compression to stderr (`furl: Bash 12.4 KB -> 0.3 KB -97%`). |
| `FURL_PRETOOL_PIPE` | on | The PreToolUse pipe (Bash-only, real savings on today's harness — see "Current harness status") runs by default. Set `0`/`false`/`off`/`no`/`disabled` (case-insensitive) to disable; unset, empty, or any other value leaves it on. Disabled is a byte-identical no-op. When any Bash permission rule exists the pipe stays off for that session unless `FURL_PIPE_WITH_RULES` is set; the SessionStart banner reports the live status. |
| `FURL_PIPE_WITH_RULES` | off | Conscious opt-in. When truthy (`1`/`true`/`on`/`yes`/`enabled`), the pipe rewrites Bash even when Bash permission rules exist, accepting that the rewrite may change how Claude Code matches those rules. No rule-present subset is provably safe, so it is off unless explicitly set and a typo never enables it. |
| `FURL_STATUS_LINE` | on | Set `0` to silence the one-line `furl … · engine furl-ctx …` SessionStart status signal. Must be exported in the environment Claude Code launches from — the status hook runs `sh -c`, which does not source login profiles. |
| `FURL_CCR_BACKEND` | `sqlite` (set by the plugin) | CCR store backend. Must match between the hook and the `furl` server for retrieval to work. |
| `FURL_CCR_SPILL` | `1` (set by the plugin) | Durable per-namespace spill tier. When on, a capacity-evicted entry is demoted to a per-project `ccr-ns-<hash>-spill.sqlite3` file instead of dropped, so its marker stays retrievable past the 1000-entry cap (bounded by the spill's own row cap and TTL). Set `0` to opt out. Must match between the hook and the `furl` server. |
| `FURL_CCR_TTL_SECONDS` | `86400` = 24h (set by the plugin) | How long offloaded originals stay retrievable before they expire. Lower to reclaim disk sooner; raise for a longer retrieval window. |
| `FURL_CCR_PROJECT_DIR` | auto, per project (set by the plugin) | Scopes the CCR store to the current project so one machine-global `~/.furl` store never surfaces or evicts another project's entries. Derived automatically from the project root; set to `""` to share one store across all projects — this is also how you read a pre-1.0 global store after upgrading. |
| `FURL_MCP_MAX_FILE_BYTES` | `41943040` = 40 MiB | Byte ceiling for `furl_compress`'s `file_path` ingest (the file the server reads from disk), separate from and larger than the 10 MiB inline-`content` cap. A real DoS bound — the model chooses the path and the server reads it with your privileges, and compression cost is super-linear in size (measured: ~34 MiB ingests in ~18–20 s, ~40 MiB in ~30 s, ~64 MiB in ~158 s) — so it is never unbounded (a blank, non-numeric, or non-positive value falls back to the default) and is set at 40 MiB, where a file at the ceiling still returns inside the MCP tool-call budget; a larger file is refused fast (`File too large to compress: N bytes (limit 41943040 bytes) — raise FURL_MCP_MAX_FILE_BYTES...`) rather than left to hang. Raise it to ingest a bigger file, accepting the extra latency. |
The full `FURL_*` reference (workspace dir, store paths, row caps, circuit breaker)
is in [`LIBRARY.md`](../../../../LIBRARY.md) → "Configuration".
## How to disable
- **Just the Claude Code hook:** set `FURL_HOOK_ENABLED=0` (leaves MCP tools available).
- **Everything:** disable the `furl` plugin in Claude Code or remove it from Codex.
## Prerequisite
The MCP server and Claude Code hooks launch through [`uv`](https://docs.astral.sh/uv/)
(`uv run --with "furl-ctx[mcp]==1.4.0" …`), which fetches Furl from PyPI on first use — no
`pip install`, no Rust toolchain. The version is pinned so every launch resolves the same
wheel deterministically instead of whatever `uv`'s cache last held; upgrades arrive through
plugin updates, which bump the pin. The
only requirement is `uv` on the PATH. If `uv` is missing, the MCP server won't start;
Claude Code's hook fails open (passes output through unchanged).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!