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, default api. cli sends complete() to claude_cli.py, which returns the same Completion, so the schema check, sanitiser, put_derived and model_run rows are shared. The run’s request records 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 own max_tokens, which the CLI gets as --model, --effort and CLAUDE_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.request records the backend too, and scrap enrich --related counts the seat token as the credential.

  • Credential. CLAUDE_CODE_OAUTH_TOKEN from claude setup-token, in the mode-600 ingest.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 with cli but no token or no claude binary pauses at start (auth, until Resume) and logs why; no job is claimed.

  • Image size. With cli the 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-config with an empty config), no settings files (--setting-sources "") or CLAUDE.md, no slash commands, no session saved; a throwaway HOME and 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 only PATH, 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 but StructuredOutput or any MCP server, or that has no init message, is discarded and pauses ingest as auth (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_error is credit; rate limits, overload, server and connection errors are outage; a request timeout (which the CLI reports as a server_error with no status, so it is tested first) is timeout; a bad request or refusal is content; cut-off or unparseable output is invalid; an error kind not yet known, or a run that gives no result at all, is internal. 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 rejected rate_limit_event) is an outage that waits for the limit’s reset time, at most an hour at a time. Overage (a rate_limit_event with isUsingOverage: past the limit, where calls may be billed for real) is a credit pause, 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_USD counts the CLI’s own total_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[…].costBasis is list): 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 own PRICES cost 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.claude builds FROM it and adds only the CLI’s native binary (no Node), pinned and checked against the release’s SHA-256: about 240 MB more. SCRAP_IMAGE names that image when SCRAP_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.env mode 600 and out of the repo. podman inspect scrap shows 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=off keeps 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_management are sent; the image may be re-encoded and its transformations are dropped; and if the model does not call StructuredOutput, the CLI makes a second request telling it to (CLAUDE_CODE_MAX_RETRIES does not cover this), so one claim can be two billed requests. request holds 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 with cli.

  • A published -claude image tag is a possible follow-up; for now it is built locally.