Skip to content

ᚲ D R A F T

draft

Research papers in. Publication-ready Markdown out.
Every sentence grounded in a fact it can prove.

Build status draftlib.com Go reference Coverage gated at 98% OpenSSF Scorecard License: MIT OR Apache-2.0 Go 1.25+


Contents

Getting started

Reference

Operational


Why draft

Grounded by construction.

A small local model will invent a plausible number. A cloud API will charge you for the privilege and want a network. draft takes neither risk.

Online, it writes through whichever AI coding-agent CLI you already have — Claude, Codex, Copilot, Cursor, Grok and more — using that tool's own logged-in session. No API key. No token budget. Offline, it falls back to a local Ollama model and stays there.

Either way, the model never gets to invent. Before a word is written, your sources are mined for claims, and a claim survives only if its quote appears verbatim in the source and every number in it appears in that quote. That verified ledger is the only factual substrate the writer is given. It arranges facts. It does not source them.

Point it at one paper or twenty. Each becomes its own draft, queued in a full-screen dashboard.


Install

With Homebrew:

brew install --cask sebastienrousseau/tap/draft

With mise's GitHub backend:

mise use --global github:sebastienrousseau/draft@latest

Or install the signed, notarized universal macOS package attached to a release:

sudo installer -pkg draft_VERSION_universal.pkg -target /

With the Go toolchain:

go install github.com/sebastienrousseau/draft/cmd/draft@latest

Or build it yourself:

git clone https://github.com/sebastienrousseau/draft
cd draft
make build          # ./bin/draft
make install        # install into GOPATH/bin

Signed binaries for macOS, Linux and Windows are attached to every release, each with a CycloneDX SBOM. Verify one:

cosign verify-blob --bundle checksums.txt.sigstore.json \
  --certificate-identity-regexp 'https://github.com/sebastienrousseau/draft/.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  checksums.txt

What you need at runtime, depending on how you run:

Tool Needed for Install (macOS)
pdftotext (Poppler) reading PDFs brew install poppler
textutil reading DOCX (macOS only) built in
a session CLI online writing, via your login claude, codex, …
ollama offline writing brew install ollama

PDF, Markdown and text work everywhere. DOCX is macOS only.


Quick start

Check the machine before committing to a run:

$ draft --doctor
SOURCE TOOLING
  ok  pdftotext              /opt/homebrew/bin/pdftotext
  ok  textutil               /usr/bin/textutil

BACKENDS
  ok  claude                 session provider
  ok  ollama                 responding at http://127.0.0.1:11434

PATHS
  ok  drafts (--out)         ~/Drop/Drafts
  ok  sources                ~/Drop/Drafts/Sources
  ok  cache                  ~/Library/Caches/draft/extract

  Ready. Run `draft --dry-run <source>` to check a specific paper.

--doctor reports the machine; --dry-run reports one paper. Between them nothing about a run should be a surprise.

# One paper. Online it picks your agent CLI; offline it uses Ollama.
# Bare filenames resolve against ~/Drop/Drafts/Sources.
draft "2603.23420.pdf"

# Three papers, three drafts, one queue.
draft a.pdf b.pdf c.pdf

# Everything else.
draft --help

Finished work lands in ~/Drop/Drafts/YYYY-MM-DD/.

Want to see the interface without spending a model call? Run go run ./examples/dashboard — the real TUI, driven by a fake engine.


How it works

Five phases. One seam.

flowchart LR
    A[Resolve<br/>sources] --> B[Read &<br/>section]
    B --> C[Extract<br/>claims]
    C --> D[Write<br/>article]
    D --> E[Validate<br/>& save]
    C -. verified<br/>ledger .-> D
    E -. rule<br/>violations .-> D
  1. Resolve sources. Bare filenames resolve against ~/Drop/Drafts/Sources; paths are taken as given. Unreadable and scanned-only files are reported here rather than halfway through a run.
  2. Read and section. Poppler extracts the text in reading order, so a two-column paper stays readable rather than having its columns spliced together. Trailing bibliography and appendix matter is dropped; the rest is split on paper headings and capped per section.
  3. Extract claims. Each section is mined for facts. A claim survives only if its SOURCE_QUOTE is an exact substring of that section, and every number in the claim appears inside that quote.
  4. Write. The claim ledger is the only permitted source of facts. If the backend stops on a length limit, draft continues generation rather than saving half an article.
  5. Validate and save. Structure, length, banned vocabulary, emoji, truncation and faithfulness are all enforced. A violation triggers a targeted rewrite, not a shrug. Scratch files are removed unless you pass --keep-artifacts.

Those five phases are pipeline.PhaseNames, in that order, and each one emits a pipeline.PhaseEvent as it starts and finishes.


Capabilities

No API key. No model download. No network required.

  • Any agent you already have. Ten CLIs supported, driven headlessly through their own sessions.
  • Offline that actually works. No up-front network probe. When a session call fails because you are on a plane, the chain advances to Ollama and stays there for the rest of the run.
  • Verbatim grounding. Quote-checked claims, numeric cross-checks, and metric-conversion detection. Unverifiable claims are dropped before writing.
  • Semantic second gate (opt-in). --second-gate runs each verified claim past a local model that judges whether its quote actually supports it — the one thing verbatim matching cannot check. Strictly additive and fail-open: it only ever tightens the ledger, and it is never the default.
  • Tables become claims. With --reader docling, numeric table cells are mined into grounded claims — the value with its row and column headers, verified against the source like any other — recovering the figures a plain-text reader flattens into noise.
  • Truncation-proof. Length-limited stops are detected and continued to a clean ending.
  • House style, enforced. Banned words and phrases in every inflection, British English, no emoji, sentence-rhythm and structure rules — checked, not merely requested.
  • Surgical review. --review asks for exact find/replace edits, applies only the unique non-overlapping ones, and re-checks the rules before saving.
  • Publish-ready sets. Body, frontmatter and combined document, written side by side and regenerable without losing a single curated field.
  • Fast where it counts. A 62-page paper is read and sectioned in ~110 ms by a 10 MB binary. See Performance.
  • A dashboard worth watching. The article streams in token by token, beside a pipeline view, a per-run log, and a focus timer.
  • Two readers, one switch. pdftotext by default: a 62-page paper in ~110 ms, text only. --reader docling when the tables matter: a layout model that keeps tables and headings, reads DOCX on every platform, and takes seconds to minutes per document. Same pipeline either way, and the extraction cache tells their sections apart.
  • PDF, DOCX, Markdown, text — and LaTeX. A .tex source is read directly with no external tool, and its maths is kept as exact text where pdftotext would scramble a formula.
  • Signed, verifiable provenance. Every article ships per-sentence attribution and a C2PA manifest; configure a certificate and draft signs a detached credential bound to the article. draft --verify re-checks the digests and, when present, the signature and trust chain; --verify --json emits a portable verification record a script or CI can gate on.
  • Keyless by default, with an escape hatch. No API key is read or stored. For a machine with no agent CLI installed, --engine api:anthropic (or api:openai) is an opt-in direct-API path — it is never chosen automatically, and the keyless session path stays the default.
  • Split local and cloud per stage. Extraction is a dozen cheap, mechanical calls; writing is one that decides the article's quality. Point them at different backends and a local model does the bulk for free while the best writer you have does the part that matters.
  • Never re-pay for extraction. A failed run leaves its verified ledger on disk; --resume re-verifies it against the sources and skips straight to writing, turning a ten-minute retry into seconds.
  • Look before you leap. --dry-run reports the sections, the routing and the model-call count in about a tenth of a second.
  • Scriptable, and priced. --print emits paths; --json emits one JSON object per job with per-phase timings and, when the backend reports it, a usage object of token counts and dollar cost; --completion writes shell completions.

Providers

In auto mode draft takes the first installed stable CLI on your PATH and drives it through its own login. No token is read, stored or logged.

Experimental providers are invoked correctly per their --help, but their article output has not been verified end to end. Auto mode skips them unless you pass --experimental. Any provider can be forced with --engine <name>.

Rows are in auto-selection preference order — the same order engine.ProviderNames() returns. Auto mode walks the list top to bottom and takes the first installed provider, skipping experimental rows unless --experimental is set.

# Provider Status Headless invocation
1 claude stable claude -p --output-format stream-json --include-partial-messages --verbose (live-streamed, prompt on stdin)
2 copilot stable copilot --acp over the Agent Client Protocol (prompt on stdin)
3 codex stable codex exec (prompt on stdin)
4 agy stable agy --input-format stream-json turn protocol (prompt on stdin)
5 cursor-agent stable cursor-agent -p --output-format text (prompt on stdin)
6 amp experimental amp -x
7 crush experimental crush run
8 goose experimental goose run --no-session -t
9 grok stable grok --output-format plain --single
10 qwen experimental qwen -p
11 claude-acp stable claude-code-acp over the Agent Client Protocol
12 gemini-acp experimental gemini --experimental-acp over the Agent Client Protocol
13 codex-acp experimental codex-acp over the Agent Client Protocol

go run ./examples/providers shows which are installed on your machine.

Direct API (escape hatch)

The keyless session path above is the default and the point of the tool: draft drives an agent CLI you are already logged into, and reads no key. For a machine with no agent CLI at all, --engine api:anthropic or --engine api:openai calls the hosted API directly, reading the key from ANTHROPIC_API_KEY or OPENAI_API_KEY and the model from DRAFT_MODEL. It is strictly opt-in — auto mode never selects it — and a failed call still falls over to Ollama like any other backend. The keyless default is what keeps your source text and your credentials on your own machine; the escape hatch exists only so a bare box is not stuck.

Agent Client Protocol

The -acp providers speak the Agent Client Protocol: JSON-RPC over the agent's stdio, one process for the whole run, and a fresh session for every call. A session carries conversation state, and claim extraction needs each section read on its own, so the process is reused and the session never is. The protocol reports why a turn stopped, so a declined prompt arrives as a typed outcome rather than an exit status.

What ACP does not buy is speed. Measured against claude-code-acp 0.16, a warm second session costs about what a cold claude -p costs, because the adapter starts an agent per session underneath. Use it for the standard transport and the cleaner failure semantics, not for the clock.

When a model declines a prompt

A model can refuse a section — incident reports that describe an intrusion are the usual case — and that is a verdict on the text, not on the provider. draft keeps the engine where it is, offers that one prompt to the next engine in the chain, and goes back to the preferred engine for the next prompt. A section that every engine declines is recorded as having no claims; an article that every engine declines fails its own job and nothing else.

The engine that actually wrote an article is recorded in its frontmatter as draft_engine, with draft_model and draft_version beside it, so the provenance of a piece written by an alternate is in the artefact, not only in the run log.


Usage

draft [flags] <source> [more-sources...]
Flag Description
--engine <mode> auto (default), ollama, or a provider name
--model <name> Session-provider model override (e.g. opus)
--experimental Let auto mode use experimental providers
--strict-numbers Fail a draft carrying a number found in no verified claim
--out <dir> Directory to write drafts into (default ~/Drop/Drafts)
--sources-dir <dir> Directory bare filenames resolve against
--no-cache Re-extract instead of reusing cached claims
--clear-cache Delete every cached claim extraction and exit
--doctor Check that this machine can run draft, and exit
--num-ctx <n> Ollama context window (default 8192)
--num-predict <n> Ollama max output tokens (default 6000)
--force-new Draft even if today's folder already has one
--merge Combine all sources into one draft
--resume Reuse a verified claim ledger from an earlier attempt
--dry-run Report what a run would do, without calling a model
--extract-engine <m> Backend for claim extraction (default: --engine)
--write-engine <m> Backend for writing the article (default: --engine)
--review <draft> Enhance an existing draft with surgical edits
--frontmatter <f> Regenerate frontmatter + final document from an article
--verify <f> Check an article against its provenance, and exit
--combine <f> Alias for --frontmatter
--keep-artifacts Keep the claim ledger beside a successful draft
--print Run without the TUI; print draft paths to stdout
--json Run without the TUI; one JSON object per job on stdout
--completion <sh> Print a completion script: bash, zsh, or fish
--version Print version and exit
-h, --help Show help

--claude-model is a deprecated alias for --model. It still parses, is absent from draft --help, and may be removed in a future release.


Article sets

One article. Three files. Always in sync. Plus two a reader can check.

2026-07-29/
├── source/2026-07-29-<slug>-body.md              # the article — edit this
├── yaml/2026-07-29-<slug>-frontmatter.yaml       # adjacent frontmatter
├── final/2026-07-29-<slug>-final.md              # combined, ready to publish
└── provenance/
    ├── 2026-07-29-<slug>-attribution.json        # which claim backs each sentence
    └── 2026-07-29-<slug>-c2pa.json               # C2PA manifest definition

The provenance pair is written once, from the run that produced the article, and is not regenerated by --frontmatter: it describes the article the ledger was verified against, and editing the body is exactly what would invalidate it. See Provenance.

Edit the body, then regenerate the other two in place:

draft --frontmatter 2026-07-29/source/2026-07-29-<slug>-body.md

Three rules govern that regeneration, and they are what make it safe to run at any time:

  1. The filename is the article's identity. Its date and slug drive every URL in the frontmatter. Retitle the article and the permalink holds.
  2. Your edits always win. Curated fields are preserved verbatim; only missing ones are generated. Delete a field to have it rebuilt.
  3. Unchanged input is a no-op. Reprocessing a set that has not changed rewrites every file byte for byte identically.

--review respects the same boundaries. The model sees the article body and never the YAML; frontmatter is re-attached on save; reviewing one file of a set resyncs its siblings.


Provenance

The ledger proves the article as a whole is grounded. Two more files make that checkable sentence by sentence and bind the article to its evidence.

Attribution. Every verified claim gets a stable identifier, c plus ten hex digits of the SHA-256 of its normalised quote, so two ledgers that verified the same quote name it the same way. The attribution file then maps every prose sentence of the body, with byte offsets, to the claims it rests on: a shared figure plus a content word, or enough content words that the overlap is not chance. Framing and transitions come out unattributed, which is expected. A sentence carrying a figure that no claim contains is flagged on that sentence, the same signal --strict-numbers fails a whole draft on.

It is computed after the fact from the text alone, deterministic and model-free, and it says so in its own field names. It is evidence of provenance, not proof.

C2PA manifest definition. A JSON manifest in the shape c2patool reads: the generator and version, a c2pa.created action naming draft as the software agent with the IPTC trained-algorithmic-media source type, the sources as ingredients, and under com.draftlib.grounding the article's digest, the ledger's digest, the extraction prompt version, the engine, model and reader, the claim identifiers, and the attribution counts.

Signing. By default draft holds no key and the manifest stays a definition — keyless, like the rest of the tool. Point DRAFT_C2PA_CERT and DRAFT_C2PA_KEY at a PEM certificate chain and its key, with c2patool installed, and draft signs the manifest into a detached .c2pa credential beside the set, bound to the body's exact bytes. A development certificate produces a valid credential that reports as untrusted rather than failing; production key custody (KMS/HSM) is yours. DRAFT_C2PA_ALG overrides the algorithm (default es256). Nothing is signed and no key is read unless you configure one.

Checking a draft. draft --verify <file> recomputes the article's digest and compares it to the manifest written beside it, so you can tell whether a draft is exactly what the ledger was verified against or has been edited since. Point it at any file of a set — the body, the final document, or the manifest — and it finds the rest by the day-folder layout. When the sources are still on the machine it hashes them too; when they are not, it says so and still checks the article. It exits non-zero if the article, the ledger or a source no longer matches.

When a signed .c2pa credential sits beside the set and c2patool is installed, --verify also validates its signature and trust chain: an altered article fails, and a development certificate is reported as valid-but-untrusted rather than failed. Add --json to emit a portable draft.verification-record/v1 receipt instead of the human report — the article digest and whether it matches, the grounding summary, the source and signature state, and the overall verdict — for a script, a CI gate, or an independent verifier. The record schema lives in the importable provenance package, so anything can consume it without the CLI.

draft --verify 2026-07-29/final/2026-07-29-<slug>-final.md
draft --verify --json 2026-07-29/final/2026-07-29-<slug>-final.md

Performance

Extraction is not the bottleneck, and it is deliberately not where the time goes.

Measured on Apple silicon (macOS 26.5, Poppler 26.06, Go 1.26), five runs each, on a 62-page book chapter:

Stage Time
Text extraction (pdftotext) 107 ms (≈580 pages/s)
Sectioning 2.1 ms — 163,530 chars → 53 sections
Claim parsing and verbatim verification 23 µs per claim block
House-rule validation of a finished draft 662 µs
The whole deterministic path ~110 ms

That rate is for a large document. A two-page paper is dominated by process startup instead, landing at 30–60 ms whatever its length.

A 10 MB binary. 29 ms to start. 12 MB peak RSS. No Python, no PyTorch, no model weights, no GPU, no network.

Everything after that is model latency. On a 12-section paper against a local Ollama model, claim extraction runs to roughly ten minutes; the Go code accounts for well under a second of it. That ratio is the whole design.

Extraction is cached by content

Extraction is where the wall clock goes, and the same section extracted by the same engine and model produces the same result. Each one is therefore stored under a hash of the section text, the extraction prompt, the engine and the model — so redrafting a paper, renaming it, or a --merge that overlaps an earlier run all skip it.

Measured on a one-section source against claude, the second run of the same paper:

Run Extract phase Ledger digest
First 15,108 ms b8d8e8e1…
Second 1 ms b8d8e8e1… (identical)

A cached entry is never trusted on its own account. It is re-parsed and re-verified against the freshly read source exactly as a fresh extraction is, so a stale entry can only ever produce fewer verified claims, never an ungrounded one — the same property that makes --resume safe. Entries expire after 30 days. --no-cache skips it; --clear-cache empties it.

How that compares

Document-understanding toolkits do far more than pull out text — layout analysis, table structure, formula recognition, OCR — and their published figures reflect that work. This is a comparison of scope, not a race:

Tool Throughput Hardware Source
liteparse (PDFium, OCR off) 1,721 pages/s B200 host Datalab
draft (Poppler) ≈580 pages/s Apple silicon measured, above
Marker, fast, no OCR (CPU) 23.7 pages/s B200 host Datalab
Docling (pypdfium backend) 2.2–2.5 pages/s Apple M3 Max Docling report
Docling 0.32 pages/s x86 CPU Docling paper
Unstructured 0.24 pages/s x86 CPU Docling paper

The trade is real, and worth stating plainly. On olmocr-bench a PDFium-class text extractor scores 20.4% overall against Docling's 50.3% and Marker's 76.0%, because that benchmark rewards table structure, LaTeX maths and scanned pages. draft attempts none of them. If your sources need any of that, use one of the tools above and hand draft the Markdown it produces. For born-digital research papers — what this is built for — the cheap path is two to three orders of magnitude faster for the text that grounding consumes.

Two failure modes used to eat that advantage. Both were found by measuring a real corpus, and both are now closed:

  • Column splicing. Preserving the visual layout merges the two columns of a paper onto shared lines, so sentences break mid-thought and join unrelated text — and a claim's quote can then never match its source. Reading order is used instead. Spliced lines across the corpus went from 158 and 59 on two papers to zero on all of them.
  • Truncation at the contents page. Sectioning cut at the first References-like heading, which in a paper with a contents listing is the front-matter entry, not the bibliography. One 62-page paper was reduced to its first 8 kB. The last such heading is used now, and that paper keeps 97.3% of its text instead of 3.9%.

Regression tests cover both against generated PDF fixtures in internal/pdf/testdata — a two-column paper with a contents listing, and a page with no text layer at all.


Configuration

Flags beat environment variables, which beat a project draft.toml, which beats a user config file, which beats the built-in defaults. Nothing about an existing setup changes: config files only fill in what a flag or variable did not set.

Config filesdraft.toml for a project or a user A `draft.toml` in the working directory sets defaults for that project; a `~/.config/draft/config.toml` (honouring `XDG_CONFIG_HOME`) sets them for you everywhere. The project file wins over the user file, and both lose to environment variables and flags — so a file is a convenience, never a surprise.
# draft.toml — flat key = value, "#" or ";" comments, [sections] ignored
engine        = "claude"
extract-engine = "ollama"   # local extraction, cloud writing
reader        = "docling"
out           = "~/Drop/Drafts"
style         = "~/.config/draft/house-style.json"
Recognised keys mirror the settings below: `engine`, `extract-engine`, `write-engine`, `edit-engine`, `reader`, `model`, `write-model`, `extract-model`, `edit-model`, `out`, `sources-dir`, `style`, and the C2PA signing keys `c2pa-cert` / `c2pa-key` / `c2pa-alg`. `DRAFT_CONFIG` names an explicit file to load instead of discovery; `DRAFT_NO_CONFIG=1` disables the file layer entirely. The parser is a dependency-free flat-key reader — no new module, no TOML library.
Environment variables | Variable | Default | Purpose | | --------------------------- | ------------------------------- | ------------------------------------------------------- | | `DRAFT_ENGINE` | `auto` | Backend selection (auto, ollama, provider) | | `DRAFT_EXTRACT_ENGINE` | — | Backend for claim extraction (default: `DRAFT_ENGINE`) | | `DRAFT_READER` | `pdftotext` | Document reader: `pdftotext` or `docling` | | `DRAFT_STYLE` | — | JSON house-style file (word band, vocabulary, language) | | `DRAFT_WRITE_ENGINE` | — | Backend for writing the article | | `DRAFT_EDIT_ENGINE` | — | Backend for `--review` edits | | `DRAFT_MODEL_SESSION` | — | Session-provider model override | | `DRAFT_MODEL` | — | Sets all Ollama models at once | | `DRAFT_WRITE_MODEL` | `gemma3:4b` | Ollama writing model | | `DRAFT_EXTRACT_MODEL` | `gemma3:4b` | Ollama claim-extraction model | | `DRAFT_EDIT_MODEL` | `gemma3:4b` | Ollama surgical-review model | | `DRAFT_NUM_CTX` | `8192` | Ollama context window | | `DRAFT_NUM_PREDICT` | `6000` | Ollama output-token ceiling (auto-scaled per draft) | | `DRAFT_WRITE_RETRIES` | `2` | Rewrite attempts on rule violations | | `DRAFT_MAX_CONTINUE` | `3` | Max continuations on a length-limited stop | | `DRAFT_EXTRACT_CONCURRENCY` | `4` | Parallel extraction workers (session engines) | | `DRAFT_CALL_TIMEOUT` | `1800` | Seconds bounding a single generation call; `0` disables | | `DRAFT_EXPERIMENTAL` | — | `1` to let auto use experimental providers | | `DRAFT_STRICT_NUMBERS` | — | `1` to fail a draft carrying an ungrounded number | | `DRAFT_SECOND_GATE` | — | `1` for an opt-in semantic second pass (see below) | | `DRAFT_C2PA_CERT` | — | PEM certificate chain for signing the C2PA credential | | `DRAFT_C2PA_KEY` | — | PEM private key paired with `DRAFT_C2PA_CERT` | | `DRAFT_C2PA_ALG` | `es256` | Signature algorithm when signing is configured | | `DRAFT_CONFIG` | — | Explicit config file (skips project/user discovery) | | `DRAFT_NO_CONFIG` | — | `1` to disable the config-file layer | | `DRAFT_DRAFTS_DIR` | `~/Drop/Drafts` | Where finished drafts are written | | `DRAFT_SOURCES_DIR` | `~/Drop/Drafts/Sources` | Where bare filenames resolve from | | `DRAFT_CACHE_DIR` | `$XDG_CACHE_HOME/draft/extract` | Cached claim extractions | | `DRAFT_NO_CACHE` | — | `1` to ignore the extraction cache | | `DRAFT_SHOW_LOGO` | — | `0` to suppress the logo in the CLI and dashboard | | `DRAFT_SITE_*` | see below | Frontmatter publisher identity | | `OLLAMA_HOST` | `http://127.0.0.1:11434` | Ollama server address | Every numeric variable is clamped at both ends. A value outside its range is not silently ignored: the default is used and a warning is printed to stderr, because a tunable you believe took effect but did not is worse than one that was rejected. The same applies to `OLLAMA_HOST` — a value that is not a valid `http`/`https` URL is refused, and one that is not loopback is reported, since a remote host means your source text leaves the machine. `DRAFT_CLAUDE_MODEL` is a deprecated alias for `DRAFT_MODEL_SESSION`, read only when the latter is unset.
Publisher identity — make the frontmatter yours Generated frontmatter carries an author, URLs, social handles and an analytics ID. Override any part of it. Unset variables keep their defaults, and curated frontmatter fields still win over generated ones. | Variable | Overrides | | --------------------------- | ----------------------------------------------- | | `DRAFT_SITE_BASE_URL` | Canonical site root for permalinks and URLs | | `DRAFT_SITE_CDN` | Asset host for banners, logos, images | | `DRAFT_SITE_NAME` | Display name | | `DRAFT_SITE_SHORT_NAME` | Slug-like identity used in asset paths | | `DRAFT_SITE_EMAIL` | Contact address for author and webmaster fields | | `DRAFT_SITE_TWITTER` | Twitter/X handle | | `DRAFT_SITE_LOCATION` | Humans.txt location | | `DRAFT_SITE_MEASUREMENT_ID` | Analytics measurement ID | | `DRAFT_SITE_COPYRIGHT_FROM` | First year of the copyright range |
Tuning the offline path — 8 GB laptops welcome The offline path is tuned for a memory-constrained machine, and gets most of its speed from four things: - **One shared model.** Extraction and writing both use `gemma3:4b`, so the server never swaps a second 4B model in and out mid-run. gemma also follows the brief closely: it keeps to the word budget and does not leak planning text into the article, so drafts usually pass the house rules first time. - **Length scaled to the evidence.** The target word count derives from the number of verified claims, and the output-token limit is sized to match. A thin ledger produces a short, fully grounded piece instead of a padded one. - **Deterministic style repair.** Banned cliché words are swapped for neutral equivalents in place, so one stray "furthermore" no longer costs a full regeneration. - **Parallel extraction.** Claims are mined two sections at a time. One request does not saturate a small GPU, so two concurrent extractions run at roughly 1.8× the throughput of one — provided the server has two slots. A server pinned to one simply queues the second call, so this is always safe. The biggest single win is how the Ollama **server** is launched. The default configuration is slow on 8 GB. Give it a quantised KV cache, flash attention and two parallel slots, and a cold run drops from minutes to under two:
# Quit the Ollama desktop app first, then:
OLLAMA_FLASH_ATTENTION=1 \
OLLAMA_KV_CACHE_TYPE=q8_0 \
OLLAMA_NUM_PARALLEL=2 \
OLLAMA_MAX_LOADED_MODELS=1 \
OLLAMA_KEEP_ALIVE=10m \
  ollama serve
On a base 8 GB Apple-silicon machine a two-section source drafts in roughly two minutes end to end. A full paper is dominated by extraction: on a measured 12-section paper, two parallel slots cut it from ~825 s to ~645 s — about a quarter faster, less than the raw 1.8× per-request gain, because the first section runs alone and uneven sections bound each pair by their slower half. `DRAFT_NUM_CTX=2048` trades a little context headroom for a smaller footprint.

Architecture

A thin cmd/ entrypoint over focused packages, each with one responsibility. The Engine interface is the seam that matters: the pipeline is identical whether a session provider or Ollama sits behind it.

cmd/draft/          CLI entrypoint, flag parsing, headless and JSON modes
config/             flag + env + default resolution
rules/              shared editorial constants (banned words, limits)
prompt/             grounded claim / writing / review prompts
claims/             claim parsing, verbatim verification, ledger
validate/           house-rule and faithfulness checks
frontmatter/        metadata extraction, YAML generation, set regeneration
engine/             Engine interface, provider registry, Ollama, routing
pipeline/           orchestration, retries, continuation, fallback chain
internal/
  pdf/              text extraction and section splitting
  brand/            logo, palette, shared styles
  tui/              Bubble Tea dashboard and queue
examples/           runnable, network-free demos of every capability

Accept interfaces, return structs. That is how the test suite drives the entire pipeline without touching a model:

// Engine is the single seam every backend implements.
type Engine interface {
    Name() string
    Generate(ctx context.Context, req Request) (Result, error)
}

// Result.Truncated tells the pipeline to continue generation rather than
// save a mid-sentence article.
type Result struct {
    Text      string
    Truncated bool
}

Library usage

draft is a command-line tool first. But every capability is an importable Go package — claims, config, engine, frontmatter, pipeline, prompt, rules and validate all live at the module root. Only the PDF extractor, the brand assets and the TUI stay internal.

go get github.com/sebastienrousseau/draft@latest

Each has its own README with a runnable quick start and an API table:

Package What it does
claims Claim parsing, the verbatim-quote gate, ledger rendering
config Flag + environment + default resolution
engine The Engine seam, provider registry, Ollama, fallback chain
frontmatter Metadata, YAML generation, article-set regeneration
pipeline Five-phase orchestration, retries, continuation, events
prompt Grounded claim, writing and review prompts
rules Shared editorial constants
validate House-rule and faithfulness checks

API stability. While the module is 0.0.x, the exported Go API may change between releases without a deprecation cycle. Pin an exact version if you depend on it, and read the CHANGELOG before upgrading — breaking changes are always listed there. The CLI's flags and output layout are the stable surface; the Go packages are not yet.

Run the pipeline in-process — one Job, streamed events
package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "path/filepath"

    "github.com/sebastienrousseau/draft/config"
    "github.com/sebastienrousseau/draft/engine"
    "github.com/sebastienrousseau/draft/pipeline"
)

func main() {
    dir, err := os.MkdirTemp("", "draft-*")
    if err != nil {
        log.Fatal(err)
    }
    defer os.RemoveAll(dir)

    // Job.Sources are absolute paths; only the CLI resolves bare names.
    src := filepath.Join(dir, "paper.txt")
    if err := os.WriteFile(src, []byte("Router-S used 5x fewer FLOPs."), 0o644); err != nil {
        log.Fatal(err)
    }

    cfg := config.Config{HomeDir: dir, DraftsDir: dir, MaxContinue: 3}
    events := make(chan pipeline.Event, 256)

    // Run is synchronous and never closes the events channel: the caller owns
    // its lifecycle, so drive it from a goroutine and close on return.
    go func() {
        defer close(events)
        pipeline.NewRunner(cfg, engine.Chain(cfg), events).
            Run(context.Background(), pipeline.Job{Sources: []string{src}})
    }()

    for e := range events {
        switch ev := e.(type) {
        case pipeline.LogEvent:
            fmt.Println("·", string(ev))
        case pipeline.PhaseEvent:
            fmt.Printf("  [%s] %s\n", pipeline.PhaseNames[ev.Index], ev.Status)
        case pipeline.DoneEvent:
            fmt.Printf("✓ %d words via %s → %s\n", ev.Words, ev.Engine, ev.OutputPath)
        case pipeline.ErrEvent:
            fmt.Println("×", string(ev)) // terminal failure; the loop ends next
        }
    }
}
`engine.Chain(cfg)` resolves the configured fallback chain and does call a real backend. Pass `[]engine.Engine{myEngine}` instead to run entirely in-process — that is how the test suite and `go run ./examples/pipeline` work. A `Job` with several sources is one merged draft (`--merge`). Setting `Job.ReviewPath` enhances that draft instead of writing a new one (`--review`).
Verify claims and build a grounded prompt — claims, prompt, validate
package main

import (
    "fmt"

    "github.com/sebastienrousseau/draft/claims"
    "github.com/sebastienrousseau/draft/prompt"
    "github.com/sebastienrousseau/draft/rules"
    "github.com/sebastienrousseau/draft/validate"
)

func main() {
    source := "Router-S used 5x fewer FLOPs than the dense baseline on the same corpus."

    // What a model returns from prompt.Claim. The second block is invented:
    // its quote does not occur in the source, so Parse drops it.
    extraction := `CLAIM: Router-S used 5x fewer FLOPs
SOURCE_QUOTE: "used 5x fewer FLOPs than the dense baseline"
TYPE: result
STRENGTH: demonstrated
---
CLAIM: Router-S halved training cost
SOURCE_QUOTE: "training cost fell by half"
TYPE: result
STRENGTH: demonstrated
---`

    records, dropped := claims.Parse(extraction, source)
    fmt.Printf("kept %d claim(s), dropped %d unverifiable\n", len(records), dropped)
    // Output: kept 1 claim(s), dropped 1 unverifiable

    // The compact ledger is the only factual substrate the writer is given,
    // capped by claim count and character budget so a small model is not swamped.
    ledger := claims.RenderPromptLedger(records, 45, 14000)
    writePrompt := prompt.Writing("", ledger, rules.MinWords, rules.MaxWords)
    fmt.Printf("writing prompt: %d chars, %d-%d words requested\n",
        len(writePrompt), rules.MinWords, rules.MaxWords)

    // Errors returns the hard violations that must block a save. Empty is clean.
    for _, e := range validate.Errors("# Too short\n\nA draft that breaks the rules.") {
        fmt.Println("✗", e)
    }
}
Generate and regenerate frontmatter — article sets
package main

import (
    "fmt"
    "log"
    "os"
    "path/filepath"
    "time"

    "github.com/sebastienrousseau/draft/frontmatter"
)

func main() {
    body := "# Router-S Cuts Compute\n\n**One number tells the story.**\n\nRouter-S used 5x fewer FLOPs.\n"

    meta := frontmatter.ExtractMetadata(body) // title, subtitle, keywords, category
    fmt.Println("title:", meta.Title)

    site := frontmatter.DefaultSite
    site.Name = "My Site"

    yaml := frontmatter.GenerateWithOptions(body, frontmatter.Options{
        Date:     time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC),
        Slug:     "router-s-cuts-compute",                     // the filename is the identity, not the headline
        Site:     &site,                                       // nil selects frontmatter.DefaultSite
        Existing: map[string]string{"author": "Ada Lovelace"}, // curated fields always win
    })
    doc := frontmatter.Combine(yaml, body) // publishable document
    fmt.Printf("combined document: %d bytes\n", len(doc))

    // ProcessFile writes the body/yaml/final set beside the input and is a
    // byte-level no-op when re-run on unchanged input.
    dir, err := os.MkdirTemp("", "draft-*")
    if err != nil {
        log.Fatal(err)
    }
    defer os.RemoveAll(dir)

    path := filepath.Join(dir, "2026-07-29-router-s-cuts-compute-body.md")
    if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
        log.Fatal(err)
    }

    bodyPath, yamlPath, finalPath, err := frontmatter.ProcessFile(path, time.Now())
    if err != nil {
        log.Fatalf("regenerating the article set: %v", err)
    }
    fmt.Println(filepath.Base(bodyPath), filepath.Base(yamlPath), filepath.Base(finalPath))
}
Bring your own backend — implement engine.Engine
package main

import (
    "context"
    "fmt"
    "strings"

    "github.com/sebastienrousseau/draft/engine"
)

// echoEngine satisfies engine.Engine. Accept interfaces, return structs: the
// pipeline is identical whichever backend sits behind this seam.
type echoEngine struct{ maxChars int }

func (echoEngine) Name() string { return "echo" }

func (e echoEngine) Generate(ctx context.Context, req engine.Request) (engine.Result, error) {
    if err := ctx.Err(); err != nil {
        return engine.Result{}, err // honour cancellation before doing work
    }
    text := strings.ToUpper(req.Prompt)
    if e.maxChars > 0 && len(text) > e.maxChars {
        // Truncated tells the pipeline to continue generation rather than
        // save a mid-sentence article.
        return engine.Result{Text: text[:e.maxChars], Truncated: true}, nil
    }
    return engine.Result{Text: text}, nil
}

func main() {
    var eng engine.Engine = echoEngine{maxChars: 12}

    res, err := eng.Generate(context.Background(), engine.Request{
        Kind:   engine.KindWrite, // KindExtract, KindWrite or KindEdit
        Prompt: "write something grounded",
    })
    if err != nil {
        fmt.Println("generate failed:", err)
        return
    }
    fmt.Printf("%s: %q truncated=%v\n", eng.Name(), res.Text, res.Truncated)
    // Output: echo: "WRITE SOMETH" truncated=true
}
The whole test suite and every example run against in-process engines like this one — no network, no model.

Examples

Every capability has a runnable demo. No model, no session CLI, no API key, no network. Start with dashboard to see the interface itself.

Example Run What it shows
dashboard go run ./examples/dashboard The real full-screen TUI driven by an in-process engine — queue, phases, live preview and focus timer, all animating; resize to watch the layout adapt
providers go run ./examples/providers Session providers in auto-selection order, install status, default models
grounding go run ./examples/grounding Claim verification against a source, ledger rendering, grounded prompt, house-rule validation
pipeline go run ./examples/pipeline The five-phase pipeline end to end, merged multi-source drafting, streamed events, day-folder output
review go run ./examples/review Surgical-edit enhancement: body-only prompting, frontmatter re-attachment, set resync
frontmatter go run ./examples/frontmatter Metadata extraction, custom Site identity, Split/Combine round trip, the three regeneration rules

Recipes for day-to-day use:

Command What it does
draft "2603.23420.pdf" Draft one paper, engine auto-selected
draft a.pdf b.pdf c.pdf Queue three papers, one draft each
draft --merge notes.md paper.pdf One draft from combined sources
draft --engine ollama paper.pdf Force the local model
draft --engine codex paper.pdf Force a specific session provider
draft --model opus paper.pdf Override the session model
draft --review draft.md paper.pdf Enhance an existing draft from its sources
draft --frontmatter source/x-body.md Regenerate the yaml and final set
draft --json paper.pdf One JSON object per job, for scripting
DRAFT_NUM_CTX=2048 draft paper.pdf Low-memory Ollama profile

When not to use draft

Honesty here saves you an evening.

  • You want a general-purpose summariser. draft drops any claim it cannot verify verbatim. A thin source yields a thin ledger and a short draft. That is the design, not a defect.
  • You have no agent CLI and no Ollama. There is no direct API mode.
  • Your sources are scans. A PDF with no text layer is reported as such, with a suggestion to run OCR first. draft does not OCR.
  • You need figures or LaTeX maths. The default reader extracts text; --reader docling keeps tables and headings but still cannot quote a figure. Feed draft Markdown you have prepared if you need more.
  • Your house style is not this house style. The word band, banned vocabulary and language variant are set by --style <file.json> (see Provenance is separate); the structural rules — an H1, a lead aside, an executive summary, section headings — are fixed, because they are the shape of a grounded article, not a matter of taste.
  • You publish a different frontmatter schema. The identity is swappable; the field set is not.
  • DOCX on Linux or Windows without Docling. The default path needs macOS textutil; --reader docling reads DOCX everywhere.

Development

make build     # compile to ./bin/draft
make install   # install into GOPATH/bin
make test      # unit + pipeline tests
make race      # tests under the race detector
make cover     # coverage report (≥98% gate, demos excluded)
make bench     # benchmarks
make fuzz      # each fuzz target briefly (FUZZTIME=30s make fuzz)
make mutation  # mutation-test the grounding gate
make vuln      # govulncheck, the same scan CI runs
make lint      # golangci-lint
make check     # fmt + vet + test
make run ARGS='--help'

The suite holds ≥98% of statements and CI fails below it. The pipeline is tested end to end against a deterministic fake Engine, so grounding, truncation-continuation and multi-provider fallback are all verified without a network call. Provider CLIs are faked via the TestHelperProcess pattern, so even the session backends are covered without spawning real agents. The parsers that read untrusted input — claim extraction, frontmatter splitting, metadata, surgical edits — are fuzzed against invariants, the most important being that a surviving claim must quote its source verbatim.

Every pull request runs build, three-OS tests, lint, an MSRV check on Go 1.25, govulncheck, CodeQL, REUSE compliance, and a base-versus-head benchmark regression check. A scheduled deep-quality workflow fuzzes every untrusted-input parser and mutation-tests the grounding gate each day.


Stability guarantees

While the module is 0.0.x, the exported Go API may change between releases without a deprecation cycle. Pin an exact version if you depend on it; every breaking change is listed in CHANGELOG.md.

Two things are treated as breaking even though neither is a Go API signature:

  • The CLI contract. Flag names, their meaning, the --json record shape, and the day-folder layout (source/, yaml/, final/). The --json record carries a schema field so a consumer can branch on it rather than sniff for fields; it is bumped when a field changes meaning or disappears, never for a pure addition.
  • What the tool produces. draft is a generator, so a change to its output is a change to its interface even when no signature moves: the frontmatter keys it emits, the structure of a generated article, and the rules a draft must satisfy to be saved. Tightening a validation rule can reject a document that previously passed, so it is announced the same way an API break is.

What is explicitly not covered: the prose a model writes. That varies by backend, model and temperature, and no version of this tool promises otherwise.

Deprecation window. A deprecated flag or environment variable keeps working for at least one minor release, is absent from --help while still parsing, and is listed under Deprecated in the changelog. --claude-model and DRAFT_CLAUDE_MODEL are the current examples.


Minimum Go policy

The floor is Go 1.25, declared in go.mod, built and tested on every pull request by the msrv CI job — the number is enforced, not aspirational.

When it may rise. Only for a concrete need: a standard-library API that removes a dependency or a hand-rolled workaround, a language feature that materially simplifies the code, or a security fix unavailable on the floor. Never merely because a newer toolchain exists.

How. A raise is a minor-version change, listed under Changed in the changelog with the reason, and the msrv job is updated in the same commit — so the floor and its test can never disagree.

On distro compatibility. No claim is made that the floor matches any particular distribution's packaged Go, because such a claim is only meaningful with a table that is kept current, and an aspirational compatibility claim is worse than none. Packagers should read the floor from go.mod, which is the single source of truth; see docs/packaging.md.


Security

  • No tokens on disk. Session backends shell out to an already authenticated CLI. draft never reads, stores or logs an API key.
  • Prompts stay out of argv where the CLI allows it. A prompt passed as a command-line argument is visible in a process listing for the duration of the call, along with the source excerpts it quotes. claude, codex, cursor-agent and goose are driven over stdin; grok receives the prompt through a 0600 file inside the call's private directory. copilot ignores stdin and offers no prompt-file flag, and agy offers only an NDJSON turn protocol, so those two still use an argument. On a shared host, prefer any of the others, or Ollama.
  • No tools are granted. draft only ever asks a provider for text, so no provider is invoked with a flag that lets the agent act on your machine. copilot --allow-all-tools and cursor-agent --force (its own help calls that an alias for --yolo, "Run Everything") were removed, and a test pins the invocation table against a list of known tool-granting flags so one cannot be reintroduced.
  • Providers run in an empty directory. Each call gets a fresh temporary working directory, removed when it returns, so a source document cannot reach an agent that has loaded the CLAUDE.md, AGENTS.md, .mcp.json or project settings sitting in whatever directory you launched draft from. DRAFT_* is stripped from the child environment.
  • Untrusted text is fenced. Source documents, the claim ledger and the draft under review are wrapped in a nonce-delimited block carrying an explicit instruction that the contents are data, never directions. The nonce is fresh per call, so a document cannot close its own block and continue as though it were the operator. The ledger is fenced too: a SOURCE_QUOTE is verbatim source by construction, so whoever controls the PDF controls what reaches the writing prompt.

This is defence in depth, not a guarantee — no prompt-level measure wins an adversarial text game outright. The empty working directory and the absence of tool grants are what bound the damage. Still prefer Ollama for material you genuinely do not trust. - Grounding as a safety control. Ungrounded numbers and silent metric conversions are flagged. Unverifiable claims never reach the writer. - Cancellation means cancellation. Quitting the dashboard, or Ctrl+C in headless mode, cancels the run's context and terminates any in-flight subprocess or Ollama request. A cancelled run stops there rather than failing over to the next backend, and never blocks waiting for a consumer that has gone away. - Bounded external calls. Extraction shells out only to pdftotext and textutil, with context timeouts, absolute paths (so a filename beginning with - cannot be read as a flag), capped output, and no shell interpolation. Generation calls are bounded by DRAFT_CALL_TIMEOUT. - Verifiable releases. macOS binaries and the universal installer package are Developer ID signed and Apple notarized; the package carries a stapled ticket for offline Gatekeeper verification. Every release is also signed with keyless Sigstore cosign and published with CycloneDX SBOMs and provenance.


Documentation

Document What it covers
DEVELOPMENT Toolchain, every CI gate reproduced locally, release model
ARCHITECTURE How the pipeline fits together, for contributors
Decision records Why the load-bearing choices were made
Packaging For distribution maintainers
Supply chain What is depended on, how it is pinned, what is checked
CHANGELOG Every released change, Keep-a-Changelog format
SUPPORT Where to take a question, and what to expect back
GOVERNANCE Who decides what, and what gets accepted
AGENTS Invariants for AI-assisted contributors
CONTRIBUTING How to propose changes and what CI expects
SECURITY Vulnerability disclosure policy
CODE_OF_CONDUCT Community standards
examples/ A runnable demo per capability
draftlib.com Project website: guides, grounding explained, examples
Go reference Package API documentation

License

Licensed under either of Apache License 2.0 or MIT License, at your option. © Sebastien Rousseau.

Unless you state otherwise, any contribution intentionally submitted for inclusion in this work by you shall be dual licensed as above, without any additional terms or conditions.

Back to top ↑