scribe

scribe is a single-binary CLI that creates and maintains a personal, LLM-written knowledge base. It continuously extracts reusable knowledge from your git repos, coding-agent sessions indexed by ccrider (Claude Code, Codex CLI, GitHub Copilot CLI, OpenCode, Pi, Antigravity, and Amp when imported by ccrider), links you iMessage to yourself as bookmarks (your own number is the world's most portable read-it-later list), and local files, then compiles it into a curated wiki that qmd indexes for semantic search. Every LLM step resolves through one top-level llm: provider block: Anthropic's Claude by default, or a 100% local Ollama server (qwen3 / gemma4 / gemma3, $0 API cost) — flipping the whole pipeline to free/offline is one line of yaml.

Not a second brain. scribe writes a personal context corpus — durable LLM memory that survives session boundaries and crosses projects. You almost never read the KB directly; Claude Code and Codex do, every session. The human is at the end of the pipeline, consuming an answer, not navigating a graph. The corpus is plain markdown in git, so it outlives the pipeline that wrote it — if scribe disappears tomorrow, the KB is still yours.

Not a RAG pipeline. Not a Karpathy-style LLM wiki. scribe keeps raw sources verbatim under raw/ AND compiles a structural wiki on top — both layers are indexed, both are searchable. Dense sources fan out into multiple entity-first wiki pages via a two-pass absorb (not one summary per source). LLM-generated retrieval-context paragraphs get spliced into every article so embedding models catch the implicit entities that aren't literally named in the text. The whole pipeline can run fully local on Ollama (qwen3 / gemma4 / gemma3, $0 API cost).

Your KB is a private git repo you own. scribe init scaffolds it from embedded templates; you pick the KB name, domains, owner context, and capture handles. After the first run you edit scribe.yaml freely — everything that used to be hardcoded lives there. Then scribe cron install drops a set of macOS LaunchAgents (Linux: paste-ready crontab lines) and the KB starts growing on its own every time you commit code, use Claude Code, or text yourself a link.


Why bother

1. Claude Code, Codex CLI and Amp become context-aware across sessions. scribe init writes a block into ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md and ~/.config/amp/AGENTS.md, parameterized with the KB name you picked (e.g. mykb, acme-notes, or whatever you chose during init). That block tells the agent to consult your KB via qmd — using the collection name that matches your KB — before answering architectural questions, recommending a library, or reproducing a pattern. Mention a project name in any of those sessions and the agent queries the KB first, pulling prior decisions, rejected tools, past solutions, and the project's own learnings log. No more re-explaining the same context every session; no more the agent suggesting the library you already evaluated and rejected six weeks ago. (Use one agent or all three — the drop-file contribution path is shared, so knowledge captured from a Codex or Amp session is searchable from a Claude session and vice versa.)

Excerpt from the block scribe init writes ({{.KBName}} gets replaced with your KB name, {{.OwnerName}} with yours):

How to search: Use the mcp__plugin_qmd_qmd__query tool when available (preferred), or qmd query "<natural language question>" via Bash. Both work from any directory — qmd collections use absolute paths, so never cd into {{.KBDir}} first.

When to search proactively — don't wait for {{.OwnerName}} to ask. The KB is only valuable if it's consulted before decisions, not after.

  • Before recommending a library, tool, or framework — query "<name> evaluation verdict". Don't suggest something already rejected.
  • Before proposing an architectural choice — query "<problem> decision reasoning". Cite the prior decision instead of reinventing it.
  • When {{.OwnerName}} references past work ("have I done this before", "didn't we decide on X", "which tool did I use for X") — these are direct instructions to search. Don't answer from memory; search.

That single prompt turns your KB into working memory for every agent session. Without it, an LLM-written KB is just a write-only archive. The full block (including the drop-file protocol for contributing from other projects) is in cmd/scribe/templates/claude-md-kb.md; the Codex variant (shell qmd instead of the MCP tool) is cmd/scribe/templates/codex-agents-md.md; the Amp variant adds one warning the others don't need — Amp threads live server-side and reach ccrider only through its opt-in importer, so an Amp session can't count on being mined and drop files carry the load. Skip any of the three with scribe init --no-claude-md / --no-codex-md / --no-amp-md.

Only the user-level files are scribe-managed. Amp also reads ~/.config/AGENTS.md and any AGENTS.md in your project tree; scribe deliberately writes neither, so the block can't leak into a repo or fight with a generic config you keep for other tools.

2. It runs itself. Set it up once, then go back to your regular work. Cron handles the rest:

  • Every hour: auto-commit the KB.
  • Every 2 hours: scan git repos for new decisions, patterns, learnings; extract them via claude -p.
  • 3×/day (03:00, 12:00, 18:00): mine coding-agent sessions via ccrider's FTS5 index — plus direct Codex CLI rollouts when codex.mine is enabled — scored by keyword density so boilerplate sessions cost nothing.
  • Every 30 min: drain queued URLs into raw/articles/.
  • Every 4 hours: pull bookmark-links you texted yourself.
  • Daily 06:30: retry previously-unfetched link stubs (capture-refetch).
  • Daily 12:30: structural lint pass over the KB.
  • Sat 01:00: weekly frontmatter auto-repair (lint-fix).
  • Sat 01:30: conflict-resolution LLM pass over contradictions (lint-resolve).
  • Sat 01:45: identity-clustering pass — finds alias clusters across people/tools (lint-identities).
  • Sat 01:55: auto-apply high-confidence alias clusters (apply-identities).
  • Sun 02:00: weekly Dream cycle — a 4-phase structured consolidation.
  • Daily 03:10: hot-domain mini-dream — auto-selects the most-touched domain since the last dream, self-gates when there's nothing to do.
  • Continuous (KeepAlive): fsnotify watcher on the ccrider DB for near-real-time session extraction (scribe watch).

Nothing demands your attention. You keep working; the KB grows. After two weeks of ordinary Mac use and no manual bookkeeping, the maintainer's personal KB looked like this (Obsidian graph view, 884 files, 45 folders, every node a wikilink in the auto-generated graph):

3. Knowledge compounds across projects. This is the killer feature for anyone juggling more than one codebase. Research, decisions, rejected tools, solved bugs, and reusable patterns live in one KB — not siloed per-project. When you hit a problem in project B that you already solved in project A six weeks ago, Claude Code surfaces the old solution automatically via the qmd query path above; manual work is qmd query "<natural-language question>" from any terminal in any directory.

Concrete examples from two weeks of the maintainer's normal use:

  • Evaluated a Go cron library for project A → two weeks later started another Go CLI in project B; Claude pulled the prior evaluation and skipped re-researching the alternatives.
  • Hit an Oban worker idempotency bug in a Phoenix project → wrote the fix into wiki/patterns/idempotent-worker-skeleton.md → three days later, different Phoenix project, same pattern surfaced without asking.
  • Tagged an iMessage bookmark about FTS5 MATCH syntax (not parameterizable) → six weeks later, SQL-injection lint fired on a different project; the KB had the exact answer and the workaround.

Without a cross-project KB, each project re-learns the same lessons. With one, the second, third, and fourth time you hit a recurring problem are all fast. The drop-file protocol (see scribe.yaml docs) means knowledge generated inside any project — .claude/scribe/YYYY-MM-DD-*.md — gets absorbed into the central KB on the next cron tick. Write once, find everywhere.

4. You own the substrate. It's a git repo of plain markdown files. Push it to your own GitHub, Gitea, or Forgejo. Open it in Obsidian, VS Code, vim, or mdbook. Grep it with ripgrep. No vendor lock-in, no cloud account, no subscription — if scribe disappears tomorrow, you still have the KB.


What you get after a week

After about a week of normal work on existing projects, the KB has grown itself into something like this. The directory shape is real (it matches scribe's wikiDirs constant); the filenames below are illustrative — yours will be whatever your actual work produces:

my-kb/
├── scribe.yaml                    # your config, edit freely
├── wiki/
│   ├── _index.md                  # auto-generated, entity index
│   ├── _hot.md                    # 500-word rolling context Claude reads on every session
│   ├── _backlinks.json            # reverse-link graph, O(1) lookup
│   ├── decisions/
│   │   ├── chose-oban-over-quantum.md
│   │   └── dropping-circuit-breaker-middleware.md
│   ├── patterns/
│   │   ├── idempotent-worker-skeleton.md
│   │   └── phoenix-scope-based-auth.md
│   ├── learnings/
│   │   ├── why-my-liveview-reconnect-loops.md
│   │   └── fts5-match-cant-be-parameterized.md
│   ├── tools/
│   │   ├── oban.md                # verdict: use
│   │   └── quantum.md              # verdict: skip, with reason
│   ├── research/
│   │   └── 2026-04-auth-library-comparison.md
│   └── projects/
│       └── my-app/
│           ├── overview.md
│           ├── learnings.md       # rolling, append-only
│           └── decisions-log.md
├── raw/articles/                  # verbatim sources (URLs, tweets, imessage clips)
└── output/runs/2026-04-*.jsonl    # per-invocation telemetry for `scribe doctor`

Every file has YAML frontmatter (type:, domain:, confidence:, tags:, related:) so you can filter and traverse programmatically. Every article links to every other article via [[wikilinks]], and scribe link auto-injects See Also sections into orphans based on shared tags. qmd query "<natural language question>" returns semantic hits with snippets — from inside the KB, from any terminal, no cd required.

One concrete loop: you work on my-app, hit a tricky bug in your LiveView, and a coding agent helps you fix it. Thirty minutes later scribe sync --sessions mines that session via ccrider's FTS5 index, extracts the root cause + fix + tradeoff, writes it into wiki/learnings/, cross-links it to wiki/patterns/phoenix-scope-based-auth.md, auto-commits + pushes. Two weeks later you hit a similar bug on a different project; qmd query surfaces the old learning before you re-debug it.


Install

Homebrew (macOS, Linux/Linuxbrew)

brew tap oliver-kriska/scribe
brew install oliver-kriska/scribe/scribe

Shell installer

curl -fsSL https://raw.githubusercontent.com/oliver-kriska/scribe/main/install.sh | bash

Pin a specific release:

curl -fsSL https://raw.githubusercontent.com/oliver-kriska/scribe/main/install.sh | bash -s -- --version v0.5.0

The installer writes to $HOME/.local/bin/scribe by default; pass --prefix to change it.

From source

Requires Go ≥ 1.26 and a C toolchain (for go-sqlite3 with FTS5 support):

git clone https://github.com/oliver-kriska/scribe.git
cd scribe
make install    # builds with -tags sqlite_fts5 to ./bin/scribe, then deploys to ~/.local/bin

make build alone compiles to the repo-local ./bin/scribe and never touches ~/.local/bin — only make install replaces the binary cron executes. On macOS, make install automatically Developer-ID-signs the binary when a Developer ID Application identity is available in the login keychain. Rebuilds signed by the same team keep their Full Disk Access grant; without that identity, the install remains unsigned and needs a fresh scribe fda after replacement.


Runtime dependencies

scribe doctor will tell you which are missing.

Tool Required Used for Install
claude yes session extraction + absorb curl -fsSL https://claude.ai/install.sh | bash
ccrider yes session database for scribe triage brew install neilberkman/tap/ccrider (or bundled as a scribe dep via Homebrew)
qmd yes semantic search over the KB npm install -g @tobilu/qmd
sqlite3 yes chat.db + ccrider reads brew install sqlite / apt install sqlite3
git yes KB auto-commit + cron sync system package
trafilatura no URL → markdown (fallback: Jina) pipx install trafilatura
jq, fzf no manual triage / preview brew install jq fzf / apt

Installing scribe via Homebrew (brew install oliver-kriska/scribe/scribe) also pulls git, sqlite, and ccrider automatically. claude, qmd, and the optionals still need their own installs.


Quick start — personal KB

If you want Claude Code or Codex to do the setup, give it the public setup runbook. It includes a copyable prompt, personal/Ollama/hosted/team recipes, safety rules, and an explicit verification checklist. scribe skill install is useful after bootstrap, but it is not a replacement for installing dependencies, choosing a setup profile, running init --bind, approving sources, and verifying cron/configuration (plus Ollama health when selected).

scribe init --path ~/my-kb --bind
cd ~/my-kb
scribe skill install
scribe sync --discover
scribe projects review
scribe sync --dry-run --estimate
scribe cron install
scribe doctor

--bind makes this KB the machine default and writes the scribe handshake into ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, and ~/.config/amp/AGENTS.md. Without it, init may deliberately leave those machine-global files untouched when another KB is already configured. The discover/review pair is the source-consent gate; the dry-run estimate does not write or call an LLM. Add a private git remote when you want backup/sync:

git remote add origin [email protected]:you/my-kb.git
git push -u origin main

scribe init will prompt for:

  • Owner name — used in the CLAUDE.md block Claude Code reads every session.
  • Owner context — one paragraph: who you are, what you work on, how you think.
  • Domains — comma-separated list for the domain: frontmatter field. personal and general are always added.
  • iMessage self-chat handle — phone number or email you iMessage yourself at (optional; leave empty to disable capture).

Everything gets written into scribe.yaml at the KB root. Re-run scribe init --check any time to re-validate against dependencies and templates without prompts or writes.

On macOS, if you gave a self-chat handle, scribe init finishes by offering to walk you through Full Disk Access for the scribe binary (needed by scribe capture). You can skip and run scribe fda later — see Full Disk Access below.

Non-interactive

All prompts take matching flags:

scribe init \
  --path ~/my-kb \
  --bind \
  --owner-name "Alice" \
  --owner-context "Platform engineer. Main projects: weblog, infra." \
  --domains weblog,infra \
  --handle "+15551234567" \
  --yes

Cron

macOS — LaunchAgents

scribe cron install     # writes ~/Library/LaunchAgents/com.scribe.*.plist
scribe cron status      # shows loaded/present/missing for each job
scribe cron uninstall   # removes agents

macOS cron runs under launchd without a login Aqua session, so it can't reach the login keychain — which breaks claude -p. scribe cron install installs the jobs as user LaunchAgents in the gui/<uid> domain instead, which do have keychain access.

The agents are KB-agnostic: each one runs scribe each, which iterates every KB in the kbs: registry (~/.config/scribe/config.yaml) and runs the job in each, with per-KB failure isolation — one machine-level agent set serves every KB, so installing from a second KB no longer clobbers the first's schedule. To pace an individual KB without per-KB plist schedules, give its scribe.yaml an each.cadence block: scribe each skips a job in that KB whenever its last ok run (output/runs/*.jsonl) is younger than the configured interval (e.g. "sync --sessions": 6h, dream: 7d).

Full Disk Access for scribe capture

macOS won't let any process read ~/Library/Messages/chat.db without Full Disk Access. Apple disallows programs from granting themselves FDA, so the toggle itself is unavoidable — but everything else is automated:

scribe fda

That command:

  1. Detects every scribe binary on disk (~/.local/bin/scribe, the mise-managed copy, etc.) and verifies each through launchd—the same parent context scheduled capture uses. An FDA-granted Terminal or coding agent therefore cannot mask a missing grant on the binary itself.
  2. Opens System Settings → Privacy & Security → Full Disk Access directly via the x-apple.systempreferences: URL.
  3. Prints the full path to each missing binary — paste it into the file picker (Cmd-Shift-G), Enter, then toggle the checkbox.
  4. Polls every 3 seconds for up to 2 minutes; flips a next to each binary the moment its grant lands.
  5. Reminds you to reload LaunchAgents (scribe cron uninstall && scribe cron install) so running jobs pick up the grant.

scribe fda --verify is the scriptable form: exit 0 if the current binary has FDA, non-zero otherwise. scribe init prompts once at the end of a fresh bootstrap; scribe doctor surfaces ungranted state in its Recent run errors section with a direct run: scribe fda fix hint.

Official macOS release binaries are Developer ID signed and notarized. The stable signing identity preserves FDA when a signed binary is replaced at the same registered path. This includes shell-installer updates to ~/.local/bin/scribe when the installer downloads a signed release archive; its source-build fallback may be unsigned. Homebrew additionally moves the raw executable to a new versioned Cellar path, which TCC records separately; after brew upgrade scribe, run scribe fda to verify the new path and re-grant it if needed.

Manual fallback if you ever need it: System Settings → Privacy & Security → Full Disk Access → + → add the paths scribe fda lists.

Linux — manual crontab

On Linux, scribe cron install prints crontab(5) lines you paste into crontab -e. Abbreviated example output (the command prints the complete block with the machine's real PATH and scribe binary):

# ---- scribe ----
SHELL=/bin/bash
PATH=/home/alice/.local/bin:/usr/bin:/bin:...

# Hourly KB auto-commit
7 */1 * * * /home/alice/.local/bin/scribe each -- commit

# Project extraction every 2h
23 */2 * * * /home/alice/.local/bin/scribe each -- sync --max 2

# Session mining at 3:00, 12:00, 18:00
0 12 * * * /home/alice/.local/bin/scribe each -- sync --sessions --sessions-max 3 --skip-large
0 18 * * * /home/alice/.local/bin/scribe each -- sync --sessions --sessions-max 3 --skip-large
0 3 * * * /home/alice/.local/bin/scribe each -- sync --sessions --sessions-max 3 --skip-large

# Drain queued URLs into raw/articles/ every 30min
*/30 * * * * /home/alice/.local/bin/scribe each -- ingest drain

# Weekly Dream cycle (Sun 2am)
0 2 * * 0 /home/alice/.local/bin/scribe each -- dream
# ---- end scribe ----

The generated jobs use scribe each so one machine-level crontab serves every KB in scribe kb list. After saving the block, verify its presence and review the machine registry directly:

crontab -l
scribe cron status
scribe kb list

scribe cron status reads the user crontab on Linux, so it confirms the block actually landed rather than just that scribe printed it.

The scribe watch job (fsnotify watcher for ccrider's SQLite DB) is not cron-friendly — run it under systemd-user, supervisord, or a persistent tmux/screen session. scribe cron install on Linux names the jobs that fall into this category so you know what still needs a supervisor.

scribe doctor works the same on either OS: it reads output/runs/*.jsonl for freshness and recent errors, so you can verify scheduled jobs are firing regardless of how they're scheduled.

The same section also watches the input side. scribe doctor --section freshness compares ccrider's newest indexed session per coding agent and warns when one agent goes quiet while the others keep flowing — the shape of an importer that broke rather than a machine that was idle. It can't know whether you simply stopped using that agent, so the row says both; it only fires while another provider was indexed in the last 48h, ignores agents with fewer than 5 sessions, and stops nagging after 90 days (that's an abandoned tool, not a broken one).


Configuration

scribe.yaml (KB root)

Primary config file. Written by scribe init, edit freely. Highlights:

owner_name: "..."
owner_context: |
  ...one paragraph Claude sees every session...

# Domains the validator accepts. 'personal' and 'general' are always accepted.
domains:
  - work
  - oss

claude_projects_dir: ~/.claude/projects
codex_sessions_dir: ~/.codex/sessions   # optional — Codex CLI rollouts; `sync --discover` walks these alongside Claude
ccrider_db: ~/.config/ccrider/sessions.db

default_model: sonnet # claude model used for extraction/dream/absorb

# Codex CLI session mining (opt-in). Project discovery from
# codex_sessions_dir above needs no extra config; this block additionally
# distills the Codex transcripts themselves into the KB — the same
# triage→envelope→wiki path ccrider sessions get, run inside
# `scribe sync --sessions`. A no-op without codex_sessions_dir. The LLM
# provider/model/prompt are inherited from session_mine: (Codex mining is
# ccrider mining with the transcript source swapped).
codex:
  mine: true            # default false — set true to turn the pass on
  sessions_max: 3       # cap mined Codex sessions per sync run
  lookback_hours: 168   # bound the rollout scan (7d); log is the real dedup
  min_score: 2          # scoreText threshold a transcript must clear

capture:
  # Use the list form (`self_chat_handles`) if you message yourself from both a
  # phone and an Apple-ID email — iMessage stores those as separate chats and
  # the singular form only reads one of them. The legacy singular still works.
  self_chat_handles:
    - "+15551234567"
    - "[email protected]"

# Keyword categories for `scribe triage`. Tune to your stack.
triage:
  keywords:
    decision: "decided OR chose OR tradeoff OR alternative"
    code_pattern: "GenServer OR LiveView OR Ecto OR ..."
    # ... architecture, research, learning, evaluation, deep_work
  weights:
    decision: 3
    code_pattern: 1
    # ... matching weights for each category

Pull integrations (Pinboard)

scribe pull fetches bookmarks from external accounts into the ingest queue, where the same drain path as ingest url fetches → contextualizes → absorbs them. Pinboard is the first adapter; the framework is generic, so more can slot in later. Adapters only produce URLs — no page content is fetched by pull itself, so it's deterministic and fast (no LLM).

1. Get your token. Grab it from https://pinboard.in/settings/password (it's username:HEXTOKEN, and grants full read+write, so treat it like a password). The token is a secret — it lives in ~/.config/scribe/config.yaml (integration_tokens.pinboard) or the SCRIBE_PINBOARD_TOKEN env var, never the committed scribe.yaml:

# ~/.config/scribe/config.yaml (per-machine, gitignored)
integration_tokens:
  pinboard: "username:HEXTOKEN"

Sanity-check it (posts/update is the cheapest, side-effect-free call):

curl -s "https://api.pinboard.in/v1/posts/update?format=json&auth_token=$SCRIBE_PINBOARD_TOKEN"
# → {"update_time":"2026-07-01T..."}  means auth works

2. Enable it in scribe.yaml (non-secret knobs only):

integrations:
  pinboard:
    enabled: true
    scope: recent+unread    # recent+unread | unread | all  (by read-state/recency)
    tags: []                # tag filter (case-insensitive); empty = all
    tags_mode: any          # any = bookmark carries >=1 listed tag (default)
                            # all = must carry EVERY listed tag (Pinboard-style)
    public_only: false      # true = skip private bookmarks; default ingests all
    skip_domains: []        # substring filter, same as capture.skip_domains
  • scope picks the set by read-state/recency: recent+unread (default — recent bookmarks plus anything flagged to-read), unread (only to-read), or all (whole archive).
  • tags is an independent tag filter; empty ingests everything the scope returned. It composes with scope — scope: all + tags: [kb] means "every bookmark I ever tagged kb".
  • tags_mode picks how multiple tags combine. The default any is an ingest gate: tags: [kb, elixir] keeps bookmarks carrying kb or elixir — the right shape when the list is "all my KB-worthy markers". Set all to require every listed tag (elixir and concurrency), which matches how Pinboard's own /t:elixir/t:concurrency/ URL filtering narrows. Note this deliberately differs from Pinboard's site default — stacking tags there narrows, while an ingest filter usually widens.
  • public_only — an authenticated pull sees your private bookmarks too, and by default they're ingested. Set public_only: true (or pass --public-only for one run) to skip private (non-shared) bookmarks — worth it if this KB might ever be shared or scribe promoted, so private links don't ride along.

3. Run it:

scribe pull --list                    # integrations + status (configured? last pull?)
scribe pull pinboard -n               # dry-run: show what WOULD be queued, write nothing
scribe pull pinboard                  # pull the configured scope + tags
scribe pull pinboard --tag kb --tag elixir   # override the tag filter for this run
scribe pull pinboard --all-history --max 200 # paced full backfill; re-run until it reports 0 new

Queued URLs land in output/inbox/ and are fetched → contextualized → absorbed by the existing ingest drain (every 30 min) — or run scribe ingest drain to process them now. A cheap posts/update probe short-circuits runs when nothing changed, so the cron job (scribe pull, hourly) is kind to Pinboard's rate limits. In a team KB, integrations are hard-off from the repo config like capture — re-enable per-person in scribe.local.yaml.

Local-mode — 100% Ollama (free, offline)

As of 0.2.14, every LLM-driven subcommand can run end-to-end against a local Ollama server with zero Anthropic calls. dream, assess, deep, session-mine, relations migrate, all four absorb passes, and contextualize resolve their backend through a single top-level llm: block. The Anthropic path stays the default; flipping the whole pipeline to free/offline is one line of yaml.

One-time setup:

brew install --cask ollama-app
# macOS: the app registers a launchd service, so `ollama serve` is already running.

Use the cask, not the ollama formula. The Homebrew formula is now built MLX-only (it depends on mlx-c) and no longer ships the GGUF/llama-server backend scribe's local pipeline needs, so a formula install leaves the LLM subcommands unable to run the recommended models.

Flip the whole pipeline to local — edit scribe.yaml:

llm:
  provider: ollama
  model: gemma3:12b              # cross-op default; per-op blocks override
  ollama_url: http://localhost:11434
  num_ctx: 16384                 # safe floor for envelope-mode ops

That's it. Every per-op block (dream, assess, deep_ingest, session_mine, relations, absorb.pass1, absorb.pass2, absorb.single_pass, absorb.facts, absorb.contextualize) inherits provider + model + ollama_url + num_ctx from llm: when its own fields are empty. Set any per-op provider:/model: to override for that op only (typical: pin a bigger model on absorb.pass2, keep the small fast model everywhere else).

Auto-flip: scribe forces the right mode on each op when llm.provider: ollama so the claude -p paths can't silently no-op. You'll see config log lines like:

config: dream.provider="ollama" forces mode=orchestrator (was "monolithic")
config: assess.provider="ollama" forces mode=envelope (was "tools")
config: session_mine.provider="ollama" forces mode=envelope (was "tools")
config: absorb.pass2_provider="ollama" forces pass2_mode=json (was "tools")

Pre-flight check:

scribe doctor --section localmode

Validates: Ollama reachable; llm.model pulled; absorb.pass2_model pulled; absorb.atomic_facts on (recommended under local pass-2); sync.daily_anthropic_output_token_ceiling configured.

What runs where on a typical 32–64 GB Mac:

Op Default model num_ctx Notes
absorb.pass1, facts gemma3:4b 8192 Cheap, high-throughput per-chunk pass
contextualize qwen3:30b-a3b 16384 Quality-critical; gemma4 is the lighter fallback if RAM-constrained
absorb.pass2 qwen3:30b-a3b 16384 Highest-quality wiki writes. On Apple Silicon MoE is ~4× faster than dense gemma3:27b at same quality
dream, assess, deep inherits llm.model 16384–32768 Envelope orchestrators
session-mine inherits llm.model 16384 Transcript inlined, capped at 24K chars

On the next scribe sync (or any subcommand) scribe will:

  1. Probe http://localhost:11434/api/tags to confirm Ollama is up.
  2. Check if the chosen model is already pulled.
  3. If not, call /api/pull in streaming mode and wait for completion (one-time download).
  4. Run generation via /api/generate with the resolved num_ctx.

No manual ollama pull needed — though ollama pull gemma3:12b && ollama pull gemma3:27b ahead of time avoids a cold-start delay on the first sync.

Recommended models (June 2026):

The table below is empirically benchmarked — every model was tested on the same two hard cases (an inverted-ratio extraction and a fabricated-date hallucination) through scribe's real contextualize pipeline. "Passes both" means it produced correct output on both; "fails" means it reproduced a defect. Pick based on your RAM budget and quality needs.

Model Size Speed When to pick
qwen3:30b-a3b-instruct-2507 ~19 GB ~15–18s/doc Best quality + speed. MoE (~3B active) → 30B-class quality at small-model speed. Richest entity coverage, best retrieval signal. Ideal for contextualize and absorb.pass2. Passes both.
gemma4 (12B dense) 9.6 GB ~25–27s/doc Best lighter fallback. Passes both, good entity coverage, 256K context. Half the RAM of qwen3. Use when RAM is tight or as a drop-in upgrade from gemma3:12b.
gemma3:12b 8.1 GB ~21–25s/doc Legacy fallback. Passes both but superseded by gemma4 (better benchmarks, same RAM class).
gemma3:4b 3.3 GB ~9–17s/doc Fast classifier. Good for absorb.pass1 (entity-list extraction) and facts. Avoid for contextualize — reproduced both benchmark defects even under the fixed pipeline.
qwen3:4b ~2.5 GB Richer prose than gemma3:4b, slightly more verbose.
phi4-mini:3.8b ~2.5 GB Reasoning-focused, less natural writing output.

All are free and work with scribe's auto-pull. Pick with model: <tag> in scribe.yaml. llama.cpp's llama-server exposes the same /api/generate shape, so ollama_url: http://localhost:8080 also works if you prefer raw llama.cpp over Ollama.

Apple Silicon tip. LLM decode is memory-bandwidth-bound, so dense 24–32B models are slow on non-Max chips (≈10 tok/s for a 27–31B on an M4 Pro). Mixture-of-Experts models like qwen3:30b-a3b-instruct activate only ~3B params per token, reaching ≈40 tok/s on Ollama (≈90 tok/s via MLX) at similar quality — the better high-quality pick when you don't have an M-series Max/Ultra. Dense gemma4 (12B) hits ~25s/doc on an M4 Pro — a good middle ground when 19 GB for the MoE model is too much.

Note. Local-mode covers every LLM-driven subcommand as of 0.2.14 — dream, assess, deep, session-mine (including Codex session mining, which inherits the session_mine: backend), relations migrate, and the four absorb passes. contextualize was first (pre-0.2.11); Phase 4A added facts_provider: ollama; Phase 4B added pass2_mode: json + pass2_provider: ollama (0.2.11); Phase 4C/4D/4E (0.2.14) ported the four remaining claude -p orchestrators onto bounded JSON-envelope subtasks, and a top-level llm: block now wires it together so flipping the whole pipeline is one line of yaml.

Hosted-mode — OpenAI-compatible providers (cheap, no local GPU)

The middle ground between free/local Ollama and Anthropic. The local path assumes a machine that can hold a quantized 12B–30B model in memory; a typical laptop can't load qwen3:30b-a3b at all. A hosted provider removes that hardware floor while keeping scribe's "bring your own model" story — you still pin qwen3-30b-a3b, it just runs on someone else's GPU. Because the workload is 89% input tokens (absorb re-reads large article context), the bill is trivial: **$2–6/month** for a busy KB, most likely ~$3–4 blended.

Any provider that speaks OpenAI-compatible /v1/chat/completions works through one backend. Built-in provider names carry their endpoint + key env var:

provider: Endpoint (built-in) API key env (default)
together https://api.together.xyz/v1 TOGETHER_API_KEY
groq https://api.groq.com/openai/v1 GROQ_API_KEY
fireworks https://api.fireworks.ai/inference/v1 FIREWORKS_API_KEY
huggingface https://router.huggingface.co/v1 HF_TOKEN
openai-compat set llm.base_url yourself set llm.api_key_env

Move the whole pipeline to the cloud (one top-level block — every op inherits it, exactly like provider: ollama). This idles the local GPU entirely:

# scribe.yaml — top-level llm block routes ALL ops
llm:
  provider: together
  model: MiniMaxAI/MiniMax-M3  # the provider's exact model slug, NOT a Claude alias
  # base_url:                 # only for provider: openai-compat
  # api_key_env: TOGETHER_API_KEY  # per-provider default; SCRIBE_LLM_API_KEY is a generic fallback
  pricing:                    # optional — lets `scribe cost` report dollars
    "together/MiniMaxAI/MiniMax-M3": { input: 0.30, output: 1.20 }

sync:
  daily_output_token_ceiling: 2000000   # REQUIRED backstop for any paid provider (see below)

Use the exact model id from the provider's catalog (e.g. Together lists MiniMaxAI/MiniMax-M3, zai-org/GLM-5.2, deepseek-ai/DeepSeek-V4-Pro, …) — the local ollama tag (qwen3:30b-a3b) won't resolve. Any chat model the provider hosts works; pick by price/quality. Watch for non-serverless models: a slug can appear in the catalog yet require a paid dedicated endpoint — a plain chat call then returns model_not_available. Verify the model is serverless before pinning it (a one-off curl to /v1/chat/completions is the fastest check).

Hosted-model benchmark (2026-07). Each model ran scribe's real absorb envelope (prompts/absorb-ollama.md) in non-streaming json_object mode — a 20-call reliability burst plus a richness probe, on the same source. "Graph output" = how well it populates the wiki's tags:/related: wikilink surface, which drives retrieval. Prices are per 1M tokens; verify current serverless availability before pinning — these slugs churn fast.

Model $/1M (in / out) Reliability (20 calls) Latency p50 Graph output When to pick
MiniMaxAI/MiniMax-M3 $0.30 / $1.20 100% valid JSON+schema, 0% fence/leak/trunc ~8s Richest — populates related:[] links, more tags/sections Best quality/$. Recommended. 1M ctx, GA serverless.
google/gemini-3.1-flash-lite (via openai-compat) $0.25 / $1.50 100% valid, 0% failure modes ~2s Thin — left related:[] empty 8/8 Fastest + cheapest/call if you don't need dense wiki links.
google/gemma-4-31B-it $0.39 / $0.97 100% valid ~35s Good Quality is fine but slow + verbose.
zai-org/GLM-5.2 $1.40 / $4.40 100% valid ~5s Good Solid, ~4× the price for no quality edge here.
deepseek-ai/DeepSeek-V4-Pro $1.74 / $3.48 100% valid ~11s Good Solid, pricey.
openai/gpt-oss-120b $0.15 / $0.60 100% valid ~40s Over-split (3 pages for 1 topic) Cheapest sticker, but slow and fragments topics.

All six produced valid, schema-clean envelopes — reliability was a wash at 100%. The real spread is latency, cost, and how richly each populates the wiki graph. For a cron pipeline (latency-insensitive) that leans on wikilink traversal, MiniMaxAI/MiniMax-M3 is the quality-per-dollar pick; a Google key + provider: openai-compat pointing at Gemini's OpenAI endpoint is the faster/cheaper alternative if terse output is fine.

Provide the key once in the user config (~/.config/scribe/config.yaml, per-machine, never in the KB) so interactive runs and cron pick it up with no shell setup:

# ~/.config/scribe/config.yaml
llm_api_key: tok-xxxxxxxx

…or export an env var for a one-off run (export TOGETHER_API_KEY=…), which overrides the file. Then:

scribe doctor --section localmode   # warns if the ceiling is unset
scribe sync

Hybrid (keep cheap passes local, offload only the heavy ones): leave llm.provider: ollama and set just the per-op blocks that pin the GPU — absorb.pass2_provider, absorb.contextualize.provider — to the hosted provider. Per-op provider/model always win over the top