backdraft

Documentation

Backdraft is three things that ship together: a Python CLI (pip install backdraft) that ingests documents and checks citations, a one-page agent skill that makes your agent write through it, and a self-contained file format for the output. There is no server and no SDK to integrate, the CLI is the whole system. This page covers install, the concepts, every command, and what ends up on disk. The normative format specs live in the repo, and agents should start from llms.txt.

Install

uv tool install backdraft

Python 3.13+. The vision-model extractor ships by default, the recommended path for real PDFs (glossy layouts, info boxes, scans) and the only path for scanned images; it activates only when BACKDRAFT_VLM_API_KEY is set. Without a key, PDFs fall back to the keyless embedded-text layer, and ingest says so. Either way PDFs want poppler on your PATH (brew install poppler) — it renders the pages, which is what puts the cited page into the artifact; without it ingest still works and says what is missing. Spreadsheets (xlsx, xlsm), CSV, Word (docx), PowerPoint (pptx), HTML, text and Markdown are keyless and built in, and sheet evidence carries the workbook's own styling: bold, fills, number formats, column widths. Legacy xls workbooks are read, values only, through the [xls] extra. Slide decks extract text only; a visual-heavy deck is better exported to PDF and ingested through the vision extractor, which captures charts and images.

Web pages

An ingest source can be an http(s) URL, not only a path. Diligence folders contain links, and a link should be as citable as a file:

backdraft ingest https://example.com/reports/q4-2025
q4-2025  https://example.com/reports/q4-2025  html  1 page  18402 chars

The page is fetched once and snapshotted like any other source. Its identity is the sha256 of the bytes fetched at that moment — the URL is provenance riding alongside, not identity — so a page that has since changed comes back as a new generation of the same document and citations into the old one report drifted, exactly as an edited PDF does.

Every surface names a fetched source by its page, which is why the line above carries a URL where a file's carries a filename. The fetch does invent a filename to stage the bytes in — q4-2025.html — but no such file is on anyone's disk, so ingest, ls, backdraft read and the References section bind --bound writes print the URL in its place rather than beside it: two names for one thing would let the invented one look authoritative.

The origin reaches the artifact as well: a receipt on a fetched page carries the URL as a link and the date the bytes were taken, and the source list shows the URL under the slug you gave the document rather than the name the fetch invented for it. The receipt says what the page said; the link is how a reader asks whether it still says it.

Ingest a stable address where the site offers one. A page that is edited will report drifted on the next bind — correct behavior, and useless as a citation, because the sentence quoted is no longer the sentence there. Wikipedia's permanent link (?oldid=), a DOI, a dated press release, an archived snapshot: each serves one revision's bytes for good. Pair it with --slug. A URL ending /index.php, /view or a bare id has no segment worth naming a document after, so the slug falls back to one built from the host (en-wikipedia-org-index) — which names the site and still not the page, and a slug is permanent once tokens carry it. Ask before you commit: backdraft ingest <source> --dry-run prints the slug and media type each source would take and stops there — nothing fetched, nothing written, no anchor minted — and says when a source is already ingested or when the name it wants is taken. The slug comes from the address alone, so the dry run settles it; a media type comes from the content type the server sends, so for a URL it stays the address's implication until the fetch. The demo cites a Wikipedia article this way.

What this does not do, said plainly rather than worked around: JavaScript-rendered pages give you whatever the server returns to a plain GET, pages behind a login are out of reach, and the extractor is a parse rather than a readability guess — navigation and footers are part of the page, because a heuristic that changed its mind between two versions of a site would move anchors. Responses are capped at 32 MiB.

Bring a model provider

The vision extractor needs a key you provide, and it runs only on explicit, backdraft-scoped consent: set BACKDRAFT_VLM_API_KEY in .backdraft/env (written by backdraft init) or the environment. Ambient OPENAI_API_KEY-style variables are deliberately never read.

Under the hood the client is the OpenAI SDK with an injectable base_url, so any OpenAI-compatible provider works. The default is OpenRouter (https://openrouter.ai/api/v1) running google/gemini-3.1-flash-lite-preview, so the simplest setup is an OpenRouter key. To point elsewhere, set BACKDRAFT_VLM_BASE_URL and BACKDRAFT_VLM_MODEL, for example directly at OpenAI or at a local server. [entail] adds the optional model-judge verifier, keyed separately by BACKDRAFT_ENTAIL_API_KEY. [math] converts LaTeX written in a document — $...$, $$...$$, \(...\), \[...\] — into MathML the browser lays out itself, so the artifact stays one file with no script and no font to fetch. Without it a formula renders verbatim rather than wrong, and render says so at exit 0 and names the install, so an artifact of raw TeX never passes for one of typeset math. Dollar amounts are never mistaken for math.

Concepts

The gate

Source documents reach the writer only through read, search and cell, which stamp a token on every span they show and record the showing in a session ledger. The set of citable things is exactly the set of things shown, so a citation to something the writer never saw is a distinguishable failure (not_shown), not an invisible one.

Tokens and receipts

bd:<slug>:<locator>:<hash>: document, place, and a content-hash of the exact text cited. Locators are media-native: p8 (a page), p8.c3 (a paragraph-scale chunk), rent-roll!B10 (a cell). An anchor is not a pointer: it carries the verbatim snippet and its sha256, so the finished artifact is defensible with the registry deleted and the sources gone.

Drift

Re-ingesting identical bytes yields identical tokens. When a source changes, citations into it resolve against the superseded snapshot and report drifted. The artifact shows what was cited and what stands now, as a word-diff.

The workflow

# once per project
backdraft init
backdraft ingest report.pdf model.xlsx notes.md
backdraft session start --id s-deal
export BACKDRAFT_SESSION=s-deal

# read through the gate; every chunk arrives with a token
backdraft read                     # list documents; a source that came back
                                   # thin is marked "little text: N chars"
backdraft read report              # table of contents (a web page lists its chunks)
backdraft read report p4-6         # pages, ranges, sheet names
                                   # 12000 chars per read; a longer page
                                   # closes with the command that continues it
backdraft search "24850000"        # hits are citable directly
backdraft search "cap rate" --limit 50 # "20 of 56 results" means capped
backdraft cell model "rent-roll!B10"
backdraft show bd:report:p4.c2:7f11 # what does this token say?

# write claims as links, then bind and render
backdraft bind memo.md --check value-trace,overlap
backdraft render memo.md --to html # -> memo.backdraft.html

# check one you were handed — the artifact or its record
backdraft verify memo.backdraft.html
In practice the reading and writing steps are an agent's: you ask for a cited memo, the backdraft skill imposes the gate, and you receive the artifact.

CLI reference

CommandDoes
initCreate .backdraft/ (registry, credentials template) in the current directory.
ingest <sources>Snapshot sources into the registry, minting every anchor. A source is a path or an http(s) URL. Formats: PDF, XLSX/XLSM, XLS, CSV/TSV, DOCX, PPTX, HTML, images (png, jpeg, tiff), text and Markdown. --extractor auto|vlm|image|pdf-text|xlsx|xls|csv|docx|pptx|html|text, --slug, --config k=v (repeatable), --dry-run. --dry-run prints the slug and media type each source would take and stops: nothing fetched, nothing written, no anchor minted. A source already in the registry says so and names the slug it already has (and that --slug would not rename it); a name another document has taken says so too, with the numbered slug it would land on instead. A slug is permanent once a token carries it, so this is how to see the default before --slug has to overrule it. Config keys are declared per extractor and checked against the one that was chosen, so an unknown key fails and names the ones that apply rather than being ignored: PDFs take dpi, snapshot_quality, snapshot_max_height; the vision paths (vlm, image) also take api_key, base_url, model, timeout, retries, and vlm alone takes concurrency (image takes no dpi — there is nothing to rasterize). Every other format reads none. A source that cannot be read does not end the run: the rest of the list is ingested anyway and the command exits 1 printing N of M sources ingested and one line per failure with its reason, so re-running the same list after a fix costs nothing. Each reason says what to do next, not just what went wrong — a directory says to name the files inside it or pass a glob, a missing path says to check the spelling, an unreadable file says to fix its permissions or ingest a copy — and a source with no bytes in it (an empty file, or a URL whose server sent an empty body) is a failure rather than a document with nothing to cite. Each source's line closes with how much text came out and which of three things happened — a document created, a new generation of one whose bytes moved (the moment citations into the previous snapshot begin reporting drifted), or unchanged, a no-op. Under a couple of hundred characters a note names the likely cause and what to do, at exit 0.
snapshot-pages <slug>Backfill page images for an already-ingested PDF, locally, no model calls (needs poppler for rendering). Ingest stores them already — this is for a registry built before it did, or on a machine that had no poppler then.
forget <slug> [--yes]Withdraw a source ingest should not have taken — a scratch copy, a duplicate under two names. It withdraws rather than deletes: the document, its generations, its anchors and its receipts all stay, so a token already written into a draft or an artifact still shows its receipt under show and still names its source in a report. What changes is that the registry stops offering it — out of ls, the document list, the table of contents, page reads and search — and bind reports its citations unresolved saying the source was withdrawn and when, rather than passing them silently. A deletion would strand those citations with nothing to say about them. Ingesting the source again brings it back as the same document under the same slug. Asks before withdrawing; --yes is the whole of the confirmation where nothing can answer.
lsList ingested documents: slug, name, media type, page count. The name is the filename, or — for a source fetched from the web — the URL it came from, standing in the staging filename's place rather than beside it. A source whose extraction came in under a couple of hundred characters closes its row with little text: N chars, the same mark the gate's own list and table of contents carry, so the signal outlives the ingest that first printed it. Ordinary rows say nothing new.
read [slug] [selector]The gate: document list, table of contents, or a token-marked page/range/sheet read. Mints what it shows. A source that extracted almost nothing is marked little text: N chars in the list and in its own headline — read it before citing it, and say so rather than citing the shell of it. One read shows 12000 characters — 200 rows of a sheet — unless --limit says otherwise, because unbounded is what lets a single read of a scraped article spend a context window with nothing in the output to say so. Every ordinary page and small range fits inside the budget and prints exactly what it always did; a page that runs past it stops on a chunk boundary and closes with [Showing 0-11531 of 34031 chars. Continue with: …], naming the command that resumes where it stopped — pass the total that line reports as --limit to read the page in one call. The cut never lands inside a chunk or a row, so a token never stands above text you were shown only part of. --offset, --limit.
search <query>Full-text search over every anchor; hits are citable. --in slug, --limit.
cell <slug> <sheet!REF>…Mint specific cells' tokens directly: token plus verbatim value.
show <token>…The inverse of minting: what a token says. Per token, its bind status, its locator and the verbatim snippet, in argument order. drifted prints the cited snippet and what stands there now; unresolved says whether the slug or the locator is wrong; malformed names the grammar. This is the gate, so what it shows is minted and citable. Exit 1 if any token was unresolved or malformed.
session start|showLedger sessions. start mints an id to export as BACKDRAFT_SESSION; show reads the ledger back — which session is in effect, and per document how many distinct anchors it has been shown, under a total. That is the coverage check before writing: what the session holds binds resolved, everything else in the registry binds not_shown, and an empty session says so and names backdraft read. A source withdrawn since it was read stays in the list, marked — the ledger records what the writer saw and that is not rewritten — but the note says that reading no longer counts as coverage. It mints nothing. Without an exported session every run in the project reads into one default ledger that is never reset, which weakens not_shown from "this writer never saw it" to "nothing here ever did" — show says so at exit 0 when that is where you are.
bind <doc.md>Resolve every citation, run --check verifiers, assemble evidence, write the record. --session, --mode frontwalk|backfill, --lean (skip page images), --bound (also write the markdown projection), --json (print the record in place of the report — the same bytes it writes, evidence included — for a caller that parses; same exit code).
render <doc.md>The artifact. --to html|footnotes|json, -o, --theme (see Theming).
verify <artifact>Check a record: the .backdraft.html artifact or its .backdraft.json sidecar. Two tiers, and the output says which ran. Against itself, needing nothing but the file: every snippet_sha256 recomputed, every token checked against the anchor it names, summary recounted from claims. Against the sources, only where a .backdraft/ is discoverable from the current directory — an artifact is a file people forward, so where it landed says nothing about which registry produced it — or where --against <project> names one, the project root or its .backdraft directory, which is how a file you were sent is checked against a project you have: every token re-resolved and reported as bind would, on a sources: line that names the registry that answered. The flag is you asserting where the file came from; verify never infers it, and a path holding no registry is refused rather than quietly checking the file alone. Read-only — no session, no minting, so an audit never makes its subject citable. Exit 2 when something did not verify; a record that faithfully carries an unresolved citation still passes. --json prints the check as one object in place of the report (backdraft/verify-v1): which tiers ran, and findings whose kindreceipt, recount, source — tells an edited file from a citation the sources no longer stand behind, without reading a sentence.
theme list|showList the bundled themes and which one is in effect; print one (validated) to stdout. See Theming.
clean [dir]Tidy a working directory: relocate stray records, remove leftover projections. Never touches authored files or artifacts.
exportThe whole registry as JSON, every generation included — the backdraft/registry-v1 format.

Bind and verify exit codes

CodeMeans
0Every citation resolved; for verify, everything it checked passed.
1Usage or environment error — for verify, a file that is missing or is not an artifact of this format, or an --against that names no registry.
2The run completed and did not come out clean: bind with something unresolved, verify with a receipt that did not hold or a citation that no longer resolves. This is the code a CI job or Stop-hook gates on, so a hook written for one catches the other. Verification verdicts (--check) never produce it.

The codes do not say why a run was not clean, and for verify the two reasons want opposite responses: a receipt that did not hold means the file was edited, a citation that does not resolve against the sources means the file is honest and the sources do not stand behind it. A hook or an agent that needs to know which should run with --json and branch on findings[].kind rather than parse the report, whose lines are worded for people and change between releases. --json never changes the exit code.

Writing rules

Claims are markdown links whose href is the token, copied exactly from gate output; multiple citations are ;-separated in one href:

[net operating income of $1,429,600](bd:t12-summary:p1.c3:f10b)
[EGI of $2,684,400](bd:t12:p1.c2:7f11;bd:model:rent-roll!B11:4b79)
  • Bind the span, not the sentence. The link text is the words the evidence supports.
  • Cite only what you were shown. Never construct or edit a token by hand.
  • Uncited prose is fine for recommendations and framing, cite facts, not opinions.
  • Never fix a failure by deleting its token. A kept failure is the honest outcome; the artifact will show it plainly.
  • An italic line directly under the # title becomes the artifact's subtitle.

Verification

Independent switches, off by default, recorded as graded evidence, never gates. value-trace finds every figure in a claim in its cited source, reading through thousands separators, currency, scale suffixes ($1.4M ≡ 1,400,000), percent-vs-decimal (7.7% ≡ 0.077), accounting negatives and dates; a match that only works after rounding is partial, and a miss names the figure. overlap measures how much of a claim's wording appears in its source (and skips single-cell sources, where the question is meaningless). A skip is never bare: the bind report prints the reason under its method's line, grouped, so a count of unchecked citations can be told apart from a hole in the verification. entail (extra) asks a model whether the source supports the claim. Verdicts appear in the artifact's Record layer in plain language.

Files & the artifact

FileIs
memo.mdWhat the writer wrote, prose plus tokens. Yours; re-bindable.
memo.backdraft.htmlThe deliverable: document, receipts and evidence in one self-contained file. No network (CSP-enforced), nothing to install, degrades to readable footnotes if scripts are stripped.
.backdraft/The machinery: registry, credentials, and the bind record (records/memo.backdraft.json, the machine-readable run, also embedded in the artifact as a JSON island with a self-describing legend).

The artifact embeds only cited evidence, a memo citing ten pages of a gigabyte corpus is a ~2 MB file. Recipients need the one .html file and nothing else.

Theming

The artifact's look is a theme, and a theme is a small TOML file. Three ship — default, press (cream stock, small-caps serif heads) and slate (a sans body, tracked uppercase heads):

backdraft render memo.md --theme slate
backdraft render memo.md --theme ./house-style.toml

To make it stick, put a file where every render will find it. Precedence, first match wins:

WhereApplies to
--theme <name|file>this render
.backdraft/theme.tomlthis project
~/.config/backdraft/theme.tomlevery project, no flag needed (honors XDG_CONFIG_HOME)
built-ineverything else

A sample file — set only the keys you want, the rest stay the built-in default:

# ~/.config/backdraft/theme.toml
name = "house"

[colors]
paper = "#FFFDF8"        # cards, panes, sheet cells
ink = "#241F1A"          # body text
sel = "#1F6F5C"          # the cited cell
alarm = "#9B3524"        # a citation that did not resolve

[fonts]
serif = "Charter, Georgia, serif"     # body text
sans = "Inter, system-ui, sans-serif" # UI text
mono = "'SF Mono', Menlo, monospace"  # code

[headings]
family = "sans"          # serif|sans|mono, or a stack of its own
case = "small-caps"      # none|uppercase|lowercase|small-caps
weight = 600             # 100–900
tracking = ".04em"

You do not have to hunt for a starting file — the CLI hands you one, fully commented, with every key and what it paints:

backdraft theme list                 # the bundled ones, and which is in effect
backdraft theme show default > ~/.config/backdraft/theme.toml
backdraft theme show ./mine.toml     # validates it; prints only what render accepts

That same list of color keys is themes/default.toml, which writes out the built-in look and is the file to copy. Two things worth knowing: serif, sans and mono name roles — body text, UI text, code — not classifications, so a sans-bodied theme sets serif; and [headings] styles the document's own title and section heads, not the small labels around them.

A theme is display only. It cannot change layout, cannot touch a token, a receipt or the record, and cannot make the artifact fetch anything — url() is refused with that reason, since the file's CSP would block the request anyway. Unknown keys and unusable values fail the render with a message naming both, so a typo costs you an error and never a half-styled artifact.

Agents and harnesses

The skill's core instruction is a substitution: for source documents, use backdraft read/search, never raw file reads. The token-efficient reference for any agent is llms.txt (~800 tokens). How the skill reaches your agent depends on the harness.

Claude Code

The repo is its own plugin marketplace, so the plugin route tracks releases:

/plugin marketplace add spencerbraun/backdraft
/plugin install backdraft@backdraft

Or have the CLI copy the skills into your skills directory:

backdraft skill install          # the writing skill, into ~/.claude/skills/
backdraft skill install --all    # plus backfill and artifact-reading
backdraft skill install --project  # into this repo's .claude/skills/

Claude Cowork

Once the plugin is listed in Anthropic's community directory, it installs from Cowork's built-in skills directory; until then, zip a skill folder from the repo's skills/ and upload it under Customize > Skills. Inside a session, run the CLI per-command as uvx backdraft ..., since installs do not persist between sessions.

Sandboxes and credentials: a sandbox usually cannot reach model providers, so ingest inside one falls back to the keyless text layer and says so. For full fidelity, ingest once on your own machine with the vision extractor. The registry lives in .backdraft/ inside the project folder, so it travels with the folder into any session, and binding and rendering never need a key. Put BACKDRAFT_VLM_API_KEY in .backdraft/env yourself; never paste keys into a chat.

Codex, Cursor, Copilot

Agents in this family read skills from ~/.agents/skills:

backdraft skill install --agent codex   # into ~/.agents/skills/

Or commit the skill folders to your repo under .agents/skills/ so every checkout carries them. For Codex cloud environments, add pip install backdraft to the setup script, which runs while the network is still on.

Standing context

For any harness that reads AGENTS.md, paste this into your repo's file:

## backdraft (cited writing)
When a document must cite its sources, write it through the backdraft CLI:
it shows source text with a citation token over every span, and only shown
spans are citable. In a sandboxed session run every command as
`uvx backdraft ...` (no install, no PATH edits). Start from
`uvx backdraft --help`; ground truth is https://backdraft.dev/llms.txt.

Security & privacy

  • Ambient keys are never read. Credentials reach backdraft only via BACKDRAFT_* variables, .backdraft/env, or --config. A generic OPENAI_API_KEY in your shell is not consent to send documents anywhere.
  • Artifacts make no network requests, enforced by a Content-Security-Policy the browser applies, no fonts, no analytics, no phone-home.
  • Everything is local. The only network calls in the system are the optional VLM/entail model calls you explicitly key.
  • Gitignore .backdraft/ for confidential corpora, the registry contains the full text of everything ingested.

FAQ

Ingest says it fell back to the text layer, why?

The note names the condition, usually that BACKDRAFT_VLM_API_KEY isn't set. Fix the named one. Text-layer receipts are fine for clean digital PDFs; glossy or scanned ones deserve the vision model.

Ingest printed a page count but almost no characters.

That is the note doing its job: the source is a shell. A scanned PDF has no text layer for pdf-text to read — ingest it through the vision extractor instead. A web page rendered by JavaScript or sitting behind a login returns a shell to an unauthenticated fetch — save the page from a signed-in browser and ingest the file. Either way the exit code stays 0, because a thin snapshot is a real snapshot; check it with backdraft read <slug> and tell whoever asked, rather than citing what came back. You do not have to have been there for the ingest: backdraft read, its table of contents and ls all mark such a source little text: N chars, so a registry that arrived with the project folder still says which of its sources are shells.

My artifact has no page images.

Ingest stores them for every PDF, through both the vision and the text-layer path — the latter renders the pages locally, which needs poppler on the machine. If ingest printed a note that it could not capture them, install poppler; then, for that registry or any built before ingest did this, run backdraft snapshot-pages <slug> (local, free) and re-bind. Also check you did not bind with --lean, which skips them deliberately.

What does not_shown actually catch?

A real token, valid in the registry, that the writing session was never shown, i.e. the writer cited something it didn't read. Only a session ledger makes this class of failure visible; that is why the skill starts one.

Can a reader trust an artifact without installing backdraft?

That's the design goal: the receipts, the evidence, and a machine-readable record with its own decoding legend are inside the file. The artifact spec lists the checks a skeptic can run with nothing but the file.

Is this fact-checking?

No, provenance. Backdraft proves where a claim came from and shows you the source; the optional verifiers add deterministic evidence like "this figure appears in that cell." Judgment stays with the reader.