Claude ingest#

Ingest is how scrap uses Claude. Every call is stored, and every result is a version that can be regenerated, edited or restored, so a later conversation can start from what the model saw and said. It builds on the design; the decisions behind it are 9. Claude ingest: Messages API, no tools, one structured call per photo, 10. Queue error classes: outages pause, charged failures count, spend is capped and 11. Whiteboards become Excalidraw drawings: model skeletons, human scenes, two sanitisers. What each call is asked, per kind of page, is in What Claude is asked, per page. Settings, routes and CLI commands are in HTTP API and CLI; to switch it on, see Enabling ingest.

The pipeline#

Everything runs on one worker thread in the service, with one queue, one pause gate and one daily budget. Capture never waits for any of it (5. Capture never waits: client-minted ids, an offline queue, no LLM on the save path).

Photos and text pages enter the queue from captures and saves; the cron-driven passes run in their own process and share the budget and the pause.

        flowchart LR
    classDef store fill:#e8f1ff,stroke:#4a7bd0
    classDef claude fill:#fff3e0,stroke:#e08a00
    classDef user fill:#e9f7ef,stroke:#2e9d5b

    subgraph IN["Triggers"]
        P["Photo capture"]:::user
        T["Note, todo, read, link"]:::user
        A["Ask Claude, Regenerate"]:::user
        C["Timers: related, topics"]:::user
    end
    subgraph SVC["Service"]
        Q["Job queue"]:::store
        W["Worker thread"]
    end
    CL["Claude"]:::claude
    subgraph OUT["Results, stored apart from the text"]
        D["derived: OCR, description, drawing"]:::store
        E["enrichment: tags, summary, related"]:::store
        U["writeup: board and topic"]:::store
    end

    P -->|"ingest job"| Q
    T -->|"enrich job"| Q
    A --> Q
    Q --> W
    W <-->|"one call per job"| CL
    W --> D
    W --> E
    W --> U
    C -->|"own process"| CL
    C --> E
    

Job

Trigger

Result

Page

ingest

A photo in a capture

Text, description, kind, title, tags, drawing, task boxes

The Claude call

ingest (a file)

A PDF or text file in any capture

Its text, for search, read locally: no model

Files: read locally, for search

enrich (reason enrich)

A text capture or save, a link, Ask Claude; a file page’s files read (SCRAP_FILE_ENRICH=auto)

Tags, summary, link title, task boxes (never from a file’s text); a file page’s title

Enrich: text pages and links

enrich (related)

First reading; nightly pass; Ask

Up to 5 related pages

Related pages

enrich (writeup)

A board photo read; Regenerate; Ask

A board’s write-up

Write-ups of boards

enrich (topic)

Weekly pass; Regenerate; Ask

A topic page’s synthesis

Topic pages

Settled: the Messages API on Opus, or claude -p on a Claude seat; Reprocess always asks before replacing a human drawing; a daily budget of $3 by default.

Not built: a cheaper first-pass model, a monthly budget (the Console spend limit caps it), a circuit breaker and a rate-limit class (the budget and Retry cover them), chat and refine (the stored runs are kept so they can start from them).

Enabling ingest#

Photos are queued whether or not ingest is on, so nothing captured before you switch it on is lost: it is sent when the worker starts. PDFs and text files are queued the same way, and wait unread (not searchable) while ingest is off: the worker that reads them is the same thread (Files: read locally, for search).

The switch is SCRAP_INGEST=on with ANTHROPIC_API_KEY set (or, on a Claude seat, SCRAP_LLM_BACKEND=cli with CLAUDE_CODE_OAUTH_TOKEN: On a Claude seat (SCRAP_LLM_BACKEND=cli)). The worker does not check the key at start, so a wrong key shows up as failed or paused photos.

  • Single server (podman). deploy/install.sh writes ~/.config/scrap/ingest.env (mode 600) and never overwrites it. Add the lines there and run systemctl --user restart scrap (Install on a server with Tailscale; every setting is in Ingest settings). It also installs the scrap-enrich-related (nightly 03:41) and scrap-enrich-topics (Sunday 04:11) timers.

  • Kubernetes (Helm). ingest.enabled, ingest.existingSecret (the chart never takes the key itself) and the optional visionModel, budgetDayUSD and notifyUrlKey: Secrets and ingest. The enrichRelated and enrichTopics CronJobs are created while ingest is enabled. The chart has no value of its own for the text model, effort, debounce or SCRAP_ENRICH; set them with extraEnv.

SCRAP_ENRICH (default on) controls the text-page job and needs ingest on; off keeps ingest to photos.

Worker#

One daemon thread from the FastAPI lifespan. Store is synchronous and SQLite has one writer, so there is no asyncio task and no second process. With SCRAP_INGEST off the thread is not started.

        sequenceDiagram
    participant Cap as Capture
    participant DB as SQLite
    participant W as Worker
    participant CL as Claude

    Cap->>DB: one ingest job per distinct image
    Cap-->>W: wake
    W->>DB: CLAIM oldest ready job (pause gate in the same UPDATE)
    W->>DB: insert model_run
    W->>CL: call, holding no lock
    CL-->>W: structured output
    W->>DB: BEGIN IMMEDIATE: finish run, write versions and page fields, job done
    
  • Enqueue. The capture transaction adds one ingest job per distinct image. A unique index, job_one_active, allows one queued or running job per photo, so a repeated photo or a second Reprocess adds nothing.

  • Claim. The thread wakes on an enqueue or every 30 s (POLL) and claims the oldest ready job whose page is not in the trash with one UPDATE ... RETURNING (ingest.CLAIM), unless the queue is paused (queue_state.paused_until). Claim and gate are one statement, so a pause cannot race a claim.

  • Order. One job at a time. A photo job is always taken before a text one (see Enrich: text pages and links).

  • Finish. In one BEGIN IMMEDIATE the worker re-reads the job (a purge counts as done), finishes the run, writes versions and page fields, and marks the job done or applies the error rules (Spend and outage control).

  • Safety. The loop catches every exception, so the thread cannot die silently, and /healthz answers 503 if it has. A job found running at start is charged as crashed (it may have killed the process), so a crashing job cannot loop the service. A clean shutdown (Worker.stop()) stops claiming and returns the job in flight to the queue for free; its run is marked service stopped.

Job states, retries and pauses are drawn on Job queue and Claude features.

The PWA shows each photo’s state under it (Capture from your phone (the PWA)): queued (“Waiting for Claude”, or “Waiting: Claude ingest is off”), processing, done, failed (with the reason and Retry) or paused (“Paused (reason): nothing is lost”, with Resume). A finished photo has a quiet Reprocess. GET /api/ingest and POST /api/ingest/resume are the queue’s status and the resume.

The Claude call#

claude.complete(model, system, messages, schema) returns the sanitised JSON, the raw content blocks, the usage and the served model.

  • The anthropic SDK, with ANTHROPIC_API_KEY from the service user’s env file; no tools; SDK debug logging off (it logs request bodies).

  • Error text is stored and shown only after redaction: anything shaped like an API key (sk-ant-...), and the key itself, is removed, in case a server or proxy ever echoes the request headers.

  • max_retries=0 and a 240 s request timeout: the queue does all the retrying, so one claim is one call. The SDK’s own retries would triple a timeout and repeat a spend-cap 429 that cannot succeed.

  • SCRAP_VISION_MODEL, default claude-opus-5-5. Its thinking cannot be disabled, so effort is set explicitly (medium) and recorded. max_tokens is 16000. A board takes seconds to a minute or so; the 240 s timeout is the bound.

Image. Claude never gets the original, but a JPEG made from it by preview.for_claude: EXIF orientation applied, HEIC converted, quality 85. It is made one decode at a time and within a memory budget (400 MiB), so an image too large to decode is refused from its header and charged as class image. The result has the largest size with a long edge of at most 2048 px (2000 px on a Claude seat, below) and ⌈w/28⌉·⌈h/28⌉ ≤ 4784 image tokens (the high-resolution cap), never above the original: a 4:3 photo is sent as 2048×1536 and a square one as 1932×1932.

The PWA itself shrinks a photo over 2560 px on the long edge (JPEG, quality 85) before it queues it, so the original that is stored is already that size.

An SVG (#480) is not decoded at all. Its job first puts its words (<title>, <desc>, <text>) as its text, with no model call, so search has them whatever happens next; then the user turn is its markup (the first 20,000 characters, an embedded font’s or image’s base64 put as its size) as a text block, said to be data, instead of an image block, with no drawing asked for. No JPEG is made, so its run has no sent_sha256 or size. See ADR 0042.

The owner’s guidance (#538): an image with Guidance for Claude (saved in its reading’s fold, image_guidance) has one more text block after the image (or the SVG’s markup), the owner’s note between <guidance> and </guidance> (any such tag in it neutralised), with a line saying it is the owner’s, not writing in the image, to follow for what to focus on, what words mean and how to draw it. The run’s stored request keeps it as guidance. The fixed prompt is the same with or without it, so its version is too. It is never sent with a write-up. See ADR 0044.

The image block sets "transformations": {"oversized_image": "error"}, so the API never rescales it silently and drawing coordinates are in sent pixels. The JPEG is stored as a blob (model_run.sent_sha256) with its image_width and image_height. The PWA scales the drawing to the displayed photo, and a later conversation can replay the exact bytes. scrap gc counts sent_sha256 as in use; after a purge it is an orphan that gc removes.

Output: kind (whiteboard, handwriting, diagram, screenshot, document, photo or other), title, tags, description, text (one item per line, (?) after uncertain readings) and elements (empty unless a whiteboard or diagram). The prompt lives in src/scrap/prompts/photo.md; prompt_version is <name>@<12 hex digits of sha256(prompt + schema)>.

Schema. It keeps to the documented structured-output subset (additionalProperties: false, no numeric or string bounds, limited unions), which [x, y] tuples would break. An element is one flat object: type, id, x and y are required, plus optional scalars or number lists, none a union: width, height, text, fontSize, label, labelFontSize, points (flat [dx0, dy0, dx1, dy1, ...]), start and end (shape ids), strokeColor and startArrowhead. Code makes Excalidraw skeletons of these and the sanitiser enforces bounds. id is required because, optional, the model leaves out every id and so every arrow.

On a Claude seat (SCRAP_LLM_BACKEND=cli)#

For a Team or Enterprise seat with no API key (ADR 0021). complete() hands the call to claude_cli.py, which runs claude -p once per call and returns the same Completion; everything after the call is shared. That is every Claude call: photos, and text enrichment (tags, summary, Ask Claude, the nightly related pass), whose text turn goes in the same way with the text model’s --effort and max_tokens (CLAUDE_CODE_MAX_OUTPUT_TOKENS). Nothing in scrap needs a tool: links are fetched by scrap.

  • Auth: CLAUDE_CODE_OAUTH_TOKEN from claude setup-token, in ingest.env. Without it, or without the claude binary, the worker pauses at start (auth) and logs why.

  • Input: one stream-json user message on stdin holding the image block (base64) and size text that scrap records; our prompt in place of Claude Code’s system prompt. The photo’s long edge is at most 2000 px, not 2048: the CLI rescales anything longer (and adds its own note on scaling coordinates). The CLI is not a pass-through: it re-encodes a JPEG over about 500 KB (same size in pixels), drops the block’s transformations, prefixes the system prompt, adds a message describing its environment, and may make a second request if the model does not call StructuredOutput (see ADR 0021).

  • Lock-down: --tools "", --permission-mode dontAsk, --strict-mcp-config with no servers, --setting-sources "", --disable-slash-commands, --no-session-persistence, --model, --effort and --json-schema on every call; a throwaway HOME and working directory; an environment of PATH, the token, proxy and CA settings only; CLI retries off, prompt caching off, 540 s for the process and its children (two request timeouts and start-up). A run whose init message offers any tool but StructuredOutput or any MCP server, or that has no init message, is discarded and pauses ingest (auth).

  • Output: the result’s structured_output; the stored content blocks are the StructuredOutput tool call, usage is the CLI’s with its total_cost_usd, which is the run’s cost_microusd and so counts towards the daily budget (a notional figure on a seat); for a model the CLI has no list price for (costBasis not list: Haiku and Sonnet 5.5 on 2.1.281), scrap’s PRICES cost the usage instead. A run killed at the deadline keeps the usage of the requests it got answers to.

  • Errors: the CLI’s error kind and status map onto the classes below. A run is failed if any request in it failed, even if the CLI went on to report a success (as it does after the API rejects the image: it drops the image and asks again without it). A seat’s usage limit (a rejected rate_limit_event) is an outage that waits for its reset (at most an hour at a time). Overage (isUsingOverage: past the limit, where calls may be billed) is a credit pause until Resume, so a person checks before more is spent; that run’s answer is not stored and the photo is sent again after Resume.

CLI error

Class

authentication_failed, oauth_org_not_allowed, account_on_hold, verification_required, model_not_found; 401, 403, 404; tools or MCP servers offered, or no init message

auth

billing_error; 402; a rate_limit_event with isUsingOverage

credit

a message saying it timed out, with no status (the CLI calls it a server_error); the process running over 540 s

timeout

a rejected rate_limit_event; rate_limit, overloaded, server_error (including no connection); 429, 5xx

outage

invalid_request (including an image the API could not process); 400, 409, 413, 422; a refusal

content

max_output_tokens, no structured output, error_max_structured_output_retries

invalid

no result at all (a crash); any other error

internal

Stored context#

A model_run row is one call: the system prompt, messages and parameters in request (images as blob references, never base64), the content blocks exactly as returned in response (thinking blocks and signatures included), the sanitised output with its warnings, usage, cost_microusd, status and error_class. A run cannot be deleted while a version uses it. The schema is in baseline.sql.

A conversation (not built yet) would continue run R by sending R’s system prompt and messages, R’s response unchanged as the assistant turn, and a new user turn, on R’s model. model_run.parent_id is there for it. MCP does not expose runs: read lists each image with its URL and what Claude read from it, and Claude’s own additions (summary, tags, related pages, a board’s write-up) under by_model. See MCP server.

Derived results and versions#

The baseline’s derived table, which holds results as versions:

  • One current row (superseded_at IS NULL, a partial unique index) per (capture, photo, kind). A drawing (excalidraw) keeps its versions: inserting one supersedes the old (a trigger), rows never change otherwise (more triggers), and Restore inserts a copy. A reading (ocr, description) keeps only the current one (ADR 39): put_derived deletes the old row, and its chunk and search entry, before it inserts the new.

  • Kinds. ocr, description (its data keeps the image kind) and excalidraw (data is {"format": "skeleton" | "scene", "width", "height", "elements"}). The table also allows summary and fetched; enrichment keeps those in enrichment instead.

  • Producer: the model id with its run_id, or human with author. A reprocess over a human drawing stores its drawing already superseded unless the user confirmed. The confirmation is kept on a job already queued or running (the worker reads it as it finishes), so it is never lost.

  • Restore (of a drawing) copies the version’s producer, run, author and text; its data adds restored_from and restored_by, so the history can say so.

Page fields come from the first photo of the page’s first capture (photos added later set none: Added photos: the first capture decides), and the user wins:

  • Title. The provisional title (first line of text, else the URL, else the device date) stays in rev 1 as captured. Only the first photo of the page’s first capture, if it has no text, may replace it, once, on the page row only, adding no revision (ingest.TITLE_CAS: it applies only while the title still equals rev 1’s and there is no later revision). The same transaction re-indexes the page. PATCH /api/pages/{id} treats a title equal to the title at its base_rev as unchanged, so the PWA, which always sends the title it loaded, never undoes the model’s. The slug never changes (the PWA uses the id).

  • Tags join the page’s own on the first reading of its first photo, the user’s first: Claude’s fill what the user’s leave of the page’s 64. A later reading (Reprocess) adds them only to a page with none, so a tag the user took off stays off. Photo tags are cleaned to lower case, words joined by -, at most 64 characters each, at most 10 per reading.

  • Kind changes only while it is an unfixed note: whiteboard and diagram become whiteboard, document becomes document (ingest.PAGE_KINDS).

Search. A trigger indexes a current version’s text as a chunk of its page (with derived_id), and superseding removes it. That covers OCR, the description, a file’s text and a human drawing’s labels; a model drawing’s text is empty, because its words are the OCR. Store.index_page rewrites only the chunks with derived_id IS NULL, and search groups hits by page.

Added photos: the first capture decides#

A page’s first capture decides what the page is; later captures only add content. Photos added to a page are read (description, OCR, drawing), searched, and fed to the write-up and related pages, but what Claude infers from them never changes the page’s kind, title or tags, or whether enrich or ingest maintains it. The owner can still change kind, title and tags by hand at any time. One SQL test says which capture is the page’s first (store.roles.FIRST_CAPTURE: the earliest received; among captures received at the same moment, as an import’s are, a file capture last, then one with no files before one with files, then the lowest id, so an import’s main capture (its text, with its images) is first). It and every other test of what a page is (who made it, whether enrich or ingest maintains it, its write-up, what automation may change, whether it takes photos) are in store/roles.py: see Page roles for the table of them.

Photos added to a page. The page editor’s Camera, Gallery and File add photos and files to a page that exists (not a topic page: its synthesis is its write-up, which a board photo would replace) as the composer’s do: each is a token in the text until Save, which sends them with the edit (PATCH /api/pages/{id} as multipart, images, PDFs and text or Markdown files). In the transaction that writes the revision they become a capture of their own (store.photos.attach): a photo capture if any is an image, else a file capture, and each token becomes its file’s embed or link. The edit is a save like any other (it queues enrich as one does), and a stale base_rev records no file. The photos are queued and read as any capture’s photos are, and a PDF or text file is read for search only (Files: read locally, for search). Following the rule above, their reading sets no title, tags or kind and writes no task boxes (A list of actions becomes task boxes), and a page made as text stays enrich’s, so its saves still queue enrich (Enrich: text pages and links). Once the photos are read, a board page gets a new write-up; a page whose first photos these are gets its related call then, and any other the nightly related pass. The page’s images are in the order the server received their captures, so the added ones come after the page’s own. POST /api/pages/{id}/captures (Store.add_photos), which appends each new image’s embed to the end of the text with no revision checked and lists a file below it, still takes an older build’s Add photo that waited on a phone, and any other client’s.

Excalidraw in the PWA#

The drawing code is a lazy chunk (web/src/board/): a dynamic import() loads @excalidraw/excalidraw and React only when a drawing is shown, so the base app stays small.

        flowchart LR
    classDef store fill:#e8f1ff,stroke:#4a7bd0
    classDef user fill:#e9f7ef,stroke:#2e9d5b

    SK[("Skeleton or scene")]:::store --> CV["Convert to elements"]
    CV --> PV["Static SVG preview"]
    PV -->|"tap"| VW["Full-screen viewer"]:::user
    VW -->|"Edit, Save"| HV[("human version")]:::store
    VW --> EX["Export: .excalidraw, PNG, SVG"]:::user
    
  • Conversion. convertToExcalidrawElements expands skeletons in the browser, with two workarounds: an arrow bound to free text carries that text’s x/y in start/end, and the scene is converted again after document.fonts.ready (the first pass exports text to a 1 px canvas so Excalidraw loads the fonts it uses).

  • On the page the drawing is a static SVG from exportToSvg, framed like the photo (its viewBox is the frame, so it lines up whatever the photo’s size) and rendered when near the screen. It takes no gestures, so a finger drag scrolls the page. ingest.drawing carries the frame, so nothing shifts when it renders. Wide screens show it beside the photo; narrow ones show one at a time, with a Photo / Drawing switch.

  • The viewer opens on a tap: a layer with a history entry (Back closes it), Excalidraw in view mode fitted to the frame, Edit switching the same scene to edit mode. Edits are kept as a draft in IndexedDB. Save stores the scene with its base version (409 if stale) as a human version whose labels become text; History has Restore. The image tool is hidden.

  • Reprocess shows Replace mine / Keep mine when the current drawing is a human one; {queued, already} in the reply says whether the photo was waiting already.

  • Fonts. Excalidraw’s drawing fonts, except Xiaolai (CJK, 14 MB), are copied into the build (/excalidraw/fonts/) and fetched only when used. The app’s CSP (font-src 'self' data:; connect-src 'self') stops Excalidraw using its CDN fallback (Chrome logs each blocked try).

  • Sizes and offline. npm run size checks them in CI: the shell’s JavaScript is budgeted at 16.5 kB gzipped; the viewer chunk is about 400 kB of JavaScript and 23 kB of CSS gzipped, the fonts about 350 kB. The service worker precaches the shell and the OFFLINE chunks in web/vite.config.ts, never the viewer or its fonts.

  • Downloads. The page’s zip carries <photo>.excalidraw beside each photo; a model skeleton is converted on the server, approximately (Excalidraw re-measures text when it opens it).

Sanitising#

Two sanitisers, in src/scrap/sanitise.py. A violation by the model drops just that element, with a warning in the run; a violation in a human scene refuses the whole save with a 422 and the editor keeps its state.

Rule

Model output (strict)

Human scene (permissive)

Element types

rectangle, ellipse, diamond, text, arrow, line

those six plus freedraw; no images, embeds, iframes or frames

Count

at most 1000 elements

the scene is bounded by size and depth

Position

finite, within a quarter of the image beyond its edges

any finite number

Points

at most 200

at most 200, or 50,000 for freedraw

Text and font

2000 characters; font size 4 to 200

no limit of their own beyond the scene’s size; fontSize must be a finite number

Ids

up to 32 characters of [A-Za-z0-9_-]

Excalidraw’s own

Colours

#rrggbb

anything Excalidraw writes (transparent, named, #rgb, #rrggbbaa, rgba())

Bindings

an arrow whose start or end names no remaining element loses that binding

Excalidraw’s own keys (index, groupIds, containerId, originalText, bindings)

Links

none

link only with http(s)

Embedded files

none

the files map is stripped

Size and depth

none

5 MB per scene (compact UTF-8 JSON), nested at most 32 levels

Unicode

lone surrogates replaced with U+FFFD

lone surrogates refused (JSON can escape half a UTF-16 pair, UTF-8 cannot encode it, so it could be stored and never served)

Prompt injection. The image is data: no tools, no network, output schema-checked and sanitised. It can change only its page’s versions, a provisional title, its tags (adding up to 10 on the first reading, alongside the user’s, which it cannot remove; none on a later one unless the page has none), an unfixed note kind and, for a list of actions, task boxes appended to the text (A list of actions becomes task boxes).

A list of actions becomes task boxes#

Photo ingest and enrich share one prompt section (prompts/items.md, added to both prompts by claude.prompt), one schema fragment (claude.INTENT_PROPERTIES) and one writer (src/scrap/actions.py), so a photographed sticky and a typed note behave alike (26. Claude appends task boxes to a list of actions).

  • Output. intent (list-of-actions, reference or other), confidence (high, medium, low) and items, each {text, done} in reading order (done for an entry crossed out or ticked). Only a page that is mainly a list of things to do is list-of-actions; the prompt says to answer reference or other when unsure. A heading such as “CURRENT ACTIONS” is not an item. Output without these fields counts as other.

  • Checked (actions.clean_items): each entry is one line of plain text of at most 200 characters, with control characters, leading list, heading, quote or checkbox marks, links (kept as their label), URL schemes and www. (nothing autolinks), character references, code, emphasis marks, backslashes, brackets and angle brackets removed; empty and repeated entries are dropped, and at most 30 are kept.

  • When it writes. Only when the intent is list-of-actions with high confidence and entries (for a photo of a whiteboard, handwriting or a document also medium, with at least three entries: doubt marks like “(?)” and doodles pull the model’s confidence down on a plain to-do list, #465; low never), and the page, as it stands when the writer runs, is not in the trash, is still at the text revision Claude read (an edit meanwhile wins and nothing is written), has no task boxes and is a note with kind_fixed 0 (for a photo also a whiteboard that ingest, not the user, chose, which becomes a note and then a todo). A saved link is never written to. For a photo it is the first image of the page’s first capture: a photo added later writes none (Added photos: the first capture decides). A fixed kind, a page with boxes and any other reading change nothing.

  • How. The entries are appended below the user’s text as - [ ] item (- [x] for done), separated by a blank line, in one text revision by claude that never folds into another (edit_text, as MCP’s edit_page). The user’s words are never rewritten, even when they are already plain bullets: the boxes go below them and the original list stays above. The task-box rule then files the page as a todo. revert with to_rev (to the revision before) restores the text; when it takes away the boxes Claude added, the page returns to the kind it was filed as (a photo’s whiteboard or document, else a note), unless the user filed or changed the todo.

  • Due date. A photo’s reading also has due: the day the page says the list is for, which the prompt works out from the day the photo was taken (given with the image), or MM-DD when the page writes no year (code takes its next occurrence), else empty. Once the boxes are written and the page is a todo with no due date, a second revision by claude sets due; revert undoes it. An empty or impossible date sets nothing, and a due date the user set is never changed.

  • Reprocess and Ask Claude read again, but a page that has boxes is skipped, so boxes are never added twice. A photo page is read again for its drawing and tags, as before.

  • Enrich runs once more. The revision is a text save, so a saved text page queues one more enrich job after the debounce, which finds the boxes and adds none.

Spend and outage control#

A price table (claude.PRICES, dollars per million tokens) turns each run’s usage into cost_microusd: claude-opus-5-5 is $4 in / $20 out, claude-sonnet-5-5 $2 / $10 and claude-haiku-5-5 $0.10 / $0.50 (for prompts up to 100k tokens, which enrich’s never reach), with cache write and read rates too. A model that is not in the table is priced at the DEAREST fallback, $10 in / $50 out, so an unlisted model’s cost is over-estimated rather than under-estimated; add the model to PRICES to get its real price.

The budget is SCRAP_BUDGET_DAY_USD (default 3), for photos and text pages together: the sum of cost_microusd of the runs started since UTC midnight, in model_run and enrich_run. It is checked when a job is claimed, not during a call, so the call that crosses the line still finishes and the day can end a little over; the Console spend limit is the hard cap. Once it is reached, the job is given back and the queue pauses (reason budget) until UTC midnight, or Resume. Resume does not raise the budget: the next claim sees the spend still over and pauses again, so raise SCRAP_BUDGET_DAY_USD and restart if you want to go on today.

Class

Recognised by

Effect

credit

402 billing_error; 400 whose message contains “reached your specified” and “API usage limits” (Console spend limit) or “credit balance”; 429 with error.details.error_code enforced_spend_limit_reached

pause until Resume

auth

401, 403, 404 (unknown model)

pause until Resume

outage

any other 429; any 5xx (including 504, 529); connection error

pause until retry-after (at most 1 h), else 1, 2, 4 … 60 min

budget

the day’s spend reached

pause until UTC midnight

timeout

APITimeoutError (240 s)

charged

invalid

max_tokens, bad output, nothing valid left (no text and no description: bad drawing elements are only dropped)

charged; retried at once

content

refusal, any other 400, 409, 413, 422

charged

image

decode or HEIC failure, too large to decode within the memory budget

charged

internal

any other exception

charged

crashed

running at service start

charged

The rule. A pause refunds the claim: the job stays queued with run_after set to the failure’s time, behind older jobs, while the pause gate waits. Resume, or the pause running out, makes it claimable again, so an outage fails nothing. Every charged failure adds 1 to job.failures and retries 10 minutes later (invalid output at once); the third kills the job (dead, shown as failed with Retry). The SDK’s APITimeoutError subclasses APIConnectionError, so it is tested first, else every timeout would be a free outage retry. A success clears the pause.

Notification. The first pause of an episode (or a pause for a new reason, such as a spend limit after a budget pause) POSTs once to SCRAP_NOTIFY_URL (off when unset; an ntfy topic works) with the fixed text scrap: Claude ingest paused ({reason}). Open scrap to resume. It names only the reason, never titles, OCR or API errors.

What is sent to Anthropic#

Only when ingest is on, only to the Claude API, and never for a page in the trash. The per-call contents are in What Claude is asked, per page; in brief:

Call

Sent

Photo

The JPEG described above (a resized, re-encoded copy; only its colour profile is kept) and the fixed prompt. Nothing else about the page

File (PDF, text)

Nothing: its text is read locally (Files: read locally, for search); the file itself is never sent

Enrich

Kind, tags, title, text (at most 12,000 characters), what was read from its images (at most 8,000), what was read from its files (on Ask at most 8,000; on a file page’s first reading, with SCRAP_FILE_ENRICH=auto, at most 4,000), the tags in use with counts (at most 600), and for a link its fetched text (at most 20,000, sent and not stored)

Related

The page in brief and an index of up to 150 candidates (title, 6 tags, summary cut to 150 characters); for a board also the start of its write-up; the start of its files’ text on Ask (3,000) and on a file page’s first reading (1,500)

Write-up

Up to 4 board JPEGs, what was read from them, the page’s text (at most 6,000), the owner’s guidance, and the related index

Topic discovery

An index of up to 2,000 pages (title, 6 tags, summary) and the topics with their sources’ numbers

Topic synthesis

Up to 40 sources (title, kind, tags, summary, write-up, image text, text: 6,000 characters each, 60,000 in all), the owner’s notes and guidance

Apart from Claude, the only outbound requests are the link fetches (to the linked site) and SCRAP_NOTIFY_URL, which gets a fixed line with the pause reason. Everything sent is kept in model_run, enrich_run and topic_run (photos as references to the stored JPEG), so your backups hold it too.

Inspecting runs#

There is no screen or MCP tool for runs (read shows only their results), so an operator reads them from the status route, the logs and the database.

Quick checks. GET /api/ingest returns whether ingest is enabled, whether the queue is paused (with reason and paused_until), jobs (photo jobs by state), enrich (whether it is on, and its jobs by state) and spent_today_usd. On a single server, call it on the socket, as the deploy page does for /healthz:

curl --unix-socket "$XDG_RUNTIME_DIR/scrap/scrap.sock" http://localhost/api/ingest

(on Kubernetes, kubectl -n scrap port-forward svc/scrap 8000:80 and the same path on http://127.0.0.1:8000). The logs are journalctl --user -u scrap, podman logs scrap or kubectl -n scrap logs deploy/scrap. The worker logs a traceback only for an unexpected exception, under ingest worker, ingest job N or enrich job N; API errors, pauses and fetch failures are not logged (apart from a failed pause notification); they are in the database.

Detail. Read scrap.db with Python’s sqlite3, because the image has no sqlite3 command. Open it read-only, inside the container where the data is at /data:

podman exec scrap python -c '
import sqlite3
c = sqlite3.connect("file:/data/scrap.db?mode=ro", uri=True)
for row in c.execute("SELECT ..."):
    print(row)'

(on Kubernetes, kubectl -n scrap exec deploy/scrap -- python -c '...'). The SELECTs below were run against a database made by scrap’s own code.

Cost per day, photos and text together (the budget’s own sum):

SELECT day, round(sum(usd), 4) FROM (
  SELECT substr(started_at, 1, 10) AS day, cost_microusd / 1e6 AS usd FROM model_run
  UNION ALL
  SELECT substr(started_at, 1, 10), cost_microusd / 1e6 FROM enrich_run)
GROUP BY day ORDER BY day DESC LIMIT 14;

The latest runs that did not succeed, with the class from the table above and the (redacted) error:

SELECT * FROM (
  SELECT 'photo' AS what, started_at, status, error_class, error
    FROM model_run WHERE status <> 'ok'
  UNION ALL
  SELECT 'text', started_at, status, error_class, error
    FROM enrich_run WHERE status <> 'ok')
ORDER BY started_at DESC LIMIT 10;

Why a link got no summary or title, newest first (a NULL is a note, which has no fetch):

SELECT p.title, json_extract(r.request, '$.fetch')
FROM enrich_run r JOIN page p ON p.id = r.page_id
WHERE json_extract(r.request, '$.purpose') = 'enrich'
  AND json_extract(r.request, '$.fetch') IS NOT NULL
ORDER BY r.started_at DESC LIMIT 10;

The fetch value is a reason from the fetch table, or {"url": ..., "chars": N} when the page was read. The queue state is in job and enrich_job (state, failures, last_error, run_after). A run with status running is a call in flight.

Two cautions. cost_microusd is an estimate from the run’s usage and claude.PRICES (see Spend and outage control), so the Console is the authority on what you spent. And the runs hold your content: model_run keeps the JPEG that was sent (sent_sha256, a blob) and enrich_run keeps the system prompt and the page text (a fetched page’s text is a placeholder), so the database holds what you sent to Claude, and your backups do too.

Command line#

Command

Use

scrap enrich --backfill

Queue the notes, todos and reads with no tags that Claude has not read; the service’s worker runs them

scrap enrich --related [--all]

Pick related pages now, in the command’s process (the nightly pass)

scrap enrich --writeups [--all | --outdated]

Write Claude’s write-up now, in the command’s process, for each board page with none (--all: every board page; --outdated: each outdated one instead)

scrap enrich --topics [--all]

Find topics now, in the command’s process (the weekly pass), then queue each topic’s synthesis that is due (--all: every topic’s) for the service’s worker

The full option lists are in HTTP API and CLI.

Troubleshooting#

Symptom

Cause

Fix

Photos say “Waiting: Claude ingest is off”

SCRAP_INGEST is not on

Set it (see Enabling ingest) and restart

Paused (auth)

The key is wrong or revoked (401, 403), or the model name is unknown (404)

Fix ANTHROPIC_API_KEY or SCRAP_VISION_MODEL, restart, then Resume

Paused (credit)

Out of credit, or at the Console spend limit

Add credit or raise the limit, then Resume

Paused (budget)

The day’s spend reached SCRAP_BUDGET_DAY_USD

Wait for UTC midnight, or raise the budget and restart; Resume alone pauses again

Paused (outage)

The API is unreachable, failing or rate limiting

Nothing: it backs off and retries; Resume tries at once

A photo says Failed

Three charged failures (see the table above); the reason is shown

Retry; for an image failure the photo may be too large to decode

A drawing is missing

The photo was not a whiteboard or diagram, or the model returned none (warnings are in the run’s output)

Reprocess

/healthz returns 503

The ingest thread has died

Read the logs and restart

A note or link is never tagged

SCRAP_ENRICH=off, ingest off, the page is still inside the debounce, or photo jobs are queued ahead of it (text runs only when no photo job is ready)

Check the settings and GET /api/ingest; Ask Claude queues it now, but still behind ready photos

A saved link has no summary or a poor title

The page could not be fetched or the site blocked scrap, so Claude had only the address and the user’s text; the PWA does not show this

Read the reason (Inspecting runs, and the table in Fetching a link); Ask Claude fetches the link again

A PDF or text file page never gets a summary

SCRAP_FILE_ENRICH=ask; ingest or enrich off; under 200 characters read; a scan (no text layer) or a file that could not be read; the file was added to a page that existed (not its first capture); an import; the page was made before this was added, or its file read with ask on; or the file was read only by a Reprocess or Retry

Ask Claude on the page

scrap enrich --related prints a note and does nothing

Ingest or enrich is off, or ANTHROPIC_API_KEY is not in the command’s environment

Run it in the container (podman exec scrap ...)

Risks#

Thinking: with structured output at effort medium the model may not think on a board (raise the effort if drawings fall short); Excalidraw API drift (the version is pinned); model drift