21. Ingest on a Claude seat: claude -p with a long-lived token#
Date: 2026-10-22
Status#
Accepted. Amends 9. Claude ingest: Messages API, no tools, one structured call per photo: the Messages API stays the default,
and a locked-down claude -p is a second backend.
Context#
Ingest needs an ANTHROPIC_API_KEY from the Claude Console. Colleagues on a corporate Claude seat
(Team or Enterprise) have no Console account, and a seat does not come with a key. ADR 0009 cut
the first design’s claude -p backend because it needed the Read tool for images and ran an
agent harness, with unclear terms, beside the service’s credentials.
Two things have changed. claude setup-token makes a long-lived OAuth token, which Claude Code
documents for CI and other headless use (the GitHub Action); whether an organisation lets a seat
be used that way, unattended, is its own policy, to check with its Claude admin. And claude -p --input-format stream-json takes the image as a base64 content block in the user message, as
the Agent SDK does, so no tool is needed. Verified with Claude Code 2.1.281 against a local fake
of the API (ANTHROPIC_BASE_URL): with --tools "" the only tool offered is the
StructuredOutput tool that --json-schema adds, and the answer comes back in the result’s
structured_output. The CLI does not pass the image block through untouched: for Opus 5.5 it
rescales an image with an edge over 2000 px (2048×1536 became 2000×1500) and adds a text block
telling the model to multiply coordinates by 1.02, which would put drawings in two frames; it
re-encodes a JPEG over about 500 KB, at the same size in pixels; and it drops the block’s
transformations, so oversized_image: error (ADR 0009) does not reach the API.
Decision#
SCRAP_LLM_BACKEND=api|cli, defaultapi.clisendscomplete()toclaude_cli.py, which returns the sameCompletion, so the schema check, sanitiser,put_derivedandmodel_runrows are shared. The run’srequestrecords the backend.Every Claude call takes it, photos and text. Text enrichment (20. Text enrichment: Claude tags, summarises and relates text pages, apart from the text: tags and summary on capture, save, backfill and Ask Claude; related pages on Ask and in the nightly
scrap enrich --related) is the same shape of call: one user turn, a schema, no tools, here text only, on the text model and effort with its ownmax_tokens, which the CLI gets as--model,--effortandCLAUDE_CODE_MAX_OUTPUT_TOKENS. Links are fetched by scrap (fetch.py), not by the model, so no tool is needed. So everything Claude does in scrap works on a seat, through the same lock-down, error classes and pauses;enrich_run.requestrecords the backend too, andscrap enrich --relatedcounts the seat token as the credential.Credential.
CLAUDE_CODE_OAUTH_TOKENfromclaude setup-token, in the mode-600ingest.env. It is passed to the CLI in its environment, never on the command line, and is redacted from error text like the API key. Ingest withclibut no token or noclaudebinary pauses at start (auth, until Resume) and logs why; no job is claimed.Image size. With
clithe photo is sent with a long edge of at most 2000 px (not 2048), so the CLI passes it through at the size scrap recorded and drawing coordinates stay in sent pixels.Lock-down, on every call. No tools (
--tools "") and tool use refused outright (--permission-mode dontAsk), no MCP servers (--strict-mcp-configwith an empty config), no settings files (--setting-sources "") or CLAUDE.md, no slash commands, no session saved; a throwawayHOMEand working directory, removed afterwards; our system prompt in place of Claude Code’s (--system-prompt); model, effort and the output schema set explicitly. The CLI gets onlyPATH, the token, proxy and CA settings, never the service’s environment. Its own retries are off (CLAUDE_CODE_MAX_RETRIES=0), so the queue does all retrying as in ADR 0010; the whole process (and anything it starts) is killed after 540 s, two request timeouts and start-up. A run whose start-up (init) message lists any tool butStructuredOutputor any MCP server, or that has noinitmessage, is discarded and pauses ingest asauth(until Resume): the photo is not charged for a fault in the set-up.Errors map onto ADR 0010’s classes from the CLI’s error kind and HTTP status: login and organisation problems and an unknown model are
auth;billing_erroriscredit; rate limits, overload, server and connection errors areoutage; a request timeout (which the CLI reports as aserver_errorwith no status, so it is tested first) istimeout; a bad request or refusal iscontent; cut-off or unparseable output isinvalid; an error kind not yet known, or a run that gives no result at all, isinternal. A run counts as failed if any request in it failed, even one the CLI went on past: when the API rejects the image, the CLI drops it, asks again without it and may report a success, which would describe a photo the model never saw. A seat’s usage limit (a rejectedrate_limit_event) is anoutagethat waits for the limit’s reset time, at most an hour at a time. Overage (arate_limit_eventwithisUsingOverage: past the limit, where calls may be billed for real) is acreditpause, which waits for Resume: an outage would retry by itself every hour and could run up a bill, while a person can check the seat’s overage setting first. That run’s answer is not stored; the photo is sent again after Resume.Budget.
SCRAP_BUDGET_DAY_USDcounts the CLI’s owntotal_cost_usd: what the call would cost at API prices, not a charge to anyone. It keeps runaway use in check against the seat’s limits with no new setting. But only for a model the CLI knows the list price of (modelUsage[…].costBasisislist): 2.1.281 does not know Haiku or Sonnet 5.5 and guesses $2/$10 per million tokens, 20 times Haiku’s, so for those scrap’s ownPRICEScost the usage, as on the API. Prompt caching is off (each input is sent once).Image. The CLI is not in the main image.
Dockerfile.claudebuildsFROMit and adds only the CLI’s native binary (no Node), pinned and checked against the release’s SHA-256: about 240 MB more.SCRAP_IMAGEnames that image whenSCRAP_LLM_BACKEND=cli.
Consequences#
The token is seat-wide: whoever holds it can use the seat for anything, with no spend cap, until it expires or is revoked. Keep
ingest.envmode 600 and out of the repo.podman inspect scrapshows it in plain text, as it does the API key.Calls draw on the same usage limits as the user’s own Claude use. A limit hit pauses ingest (photos and text enrichment, which share the queue) until the reset, and heavy ingest can bring the user’s interactive limit closer. Text enrichment adds a call per note or link saved, and the nightly related pass one per changed page;
SCRAP_ENRICH=offkeeps a seat to photos.Users must check their organisation’s policy (ask its Claude admin) before using a seat unattended; the docs say so.
The CLI’s request is not the API backend’s request, so a CLI run is not a replay of an API run. Seen with 2.1.281: the system prompt is prefixed with a billing-header block and an Agent SDK line (“You are a Claude agent, built on Anthropic’s Claude Agent SDK.”); a user message describing the environment (the throwaway working directory, platform, kernel, date) is added; adaptive thinking and
context_managementare sent; the image may be re-encoded and itstransformationsare dropped; and if the model does not callStructuredOutput, the CLI makes a second request telling it to (CLAUDE_CODE_MAX_RETRIESdoes not cover this), so one claim can be two billed requests.requestholds what scrap sent, and the CLI version is pinned.Usage and cost are as the CLI reports them; served model, content blocks and usage are stored as for the API. A run killed at the deadline keeps the usage of the requests it got answers to (costed from their tokens, as the CLI’s own cost never arrived); one killed mid-request records none.
The CLI peaks at about 250 MB of memory beside scrap, so a higher
SCRAP_MEMORY_MAX(1.5g) is advised withcli.A published
-claudeimage tag is a possible follow-up; for now it is built locally.