examples/receipt-extraction/
(extract_receipts.py, a stubbed-vision test, and a manual end-to-end script).
How it flows
- Email source node — receives the email. Attachments (PNG/JPEG/PDF/etc.)
are registered as blob artifacts on the node’s output, and a
receipts_inboxmetadata table (one row per attachment, including acontent_hash— the sha256 of the bytes) is emitted for preview and lineage. The raw bytes are uploaded format-agnostically by the SMTP ingest boundary, so non-tabular formats are not garbled. Blobs are immutable and content-addressed — this is the bronze layer everything downstream can be re-derived from. - The worker stages those blobs to local files before the downstream
Python node runs, and exposes them through the
input_files()SDK helper — eachInputFilecarries the bytes, filename, content type, andcontent_hash. Reading them needs no network access and no read credentials. - Python transform node —
input_files()enumerates the staged receipts; for each (up to 4 concurrently), the node base64-encodes the bytes and calls the Claude Messages API with a structured-extraction prompt — animagecontent block for images, adocumentcontent block for PDFs. output_table(rows)— the flattened rows (one per receipt) are materialized as a DuckLake table you can query or visualize. With the node configured for append + dedup (below), the table accumulates across runs as a silver layer.
1. Set up the email source node
Add an email source node to your project and copy its ingest address (it looks like<hookid>@ingest.<your-domain>). Anything emailed to that address
lands on the node. Send a test email with one or more receipts attached;
the node’s receipts_inbox table should show one row per attachment, each
with its content_hash.
2. Add the ANTHROPIC_API_KEY secret
The Python node reads the key with get_secret("ANTHROPIC_API_KEY"):
- Open the project’s Secrets panel.
- Add a secret named
ANTHROPIC_API_KEYwith your Anthropic API key as the value. - Attach the secret to the Python node (add it to the node’s secret refs).
api.anthropic.com, so the vision call works once the secret is set. The key is
injected into the sandbox config and is never written to disk or logged.
3. Add the Python transform node
Create a Python transformation node downstream of the email node and paste the body ofextract_receipts.py,
ending the script with an explicit run() call.
Then set two node params (this is what makes the table accumulate — see
Accumulating across runs):
output_mode="append"dedup_keys=["content_hash", "prompt_version"]
What each receipt extracts to
One output row per receipt — successful or not — withline_items serialized
to a JSON string column so the row stays flat and DuckLake-friendly:
Downstream, explode
line_items with DuckDB’s json_extract / UNNEST, or
keep it as-is for a per-receipt summary. Filter status = 'ok' for analysis
queries; keep an eye on status = 'error' rows for receipts that need a
retry or a better photo.
Per-receipt error isolation
One bad receipt does not abort the batch. Each receipt’s vision call and JSON parse are isolated: on failure the node emits a row withstatus = 'error',
the truncated message in error, and the receipt’s identity columns
(source_file, content_hash) — and continues with the rest. Because the
error row carries the same dedup keys, a later re-run that succeeds for that
receipt upserts over the error row.
Accumulating across runs (append + dedup)
The accumulation behavior is node configuration, not SDK code. Set on the Python node:output_mode="append"dedup_keys=["content_hash", "prompt_version"]
- Re-runs upsert. A receipt already extracted with the same bytes
(
content_hash) and the same prompt (prompt_version) replaces its previous row instead of duplicating it — re-running the node, re-sending an email, or retrying after errors is always safe. - Bumping
PROMPT_VERSIONre-extracts everything under the new version alongside the old rows. Comparev1vsv2extractions side by side, then filter onprompt_versiondownstream once you trust the new prompt. - Silver is always re-derivable. The email node’s bronze blob artifacts
are immutable and content-addressed, so you can clear the extracted table
and re-run from scratch to get identical rows back (modulo
extracted_atand model non-determinism).
Why the model id and message shapes are exact
- Model:
claude-sonnet-4-6. Receipt extraction is simple, well-specified structured vision work — it doesn’t need Opus-tier reasoning, and the Sonnet tier is the current fast/cheap vision-capable choice (15 per MTok vs 25 forclaude-opus-4-8) with both image and PDF support. Use the bare id — do not append a date suffix. The model id is recorded in each row’smodelcolumn, so a later model change is visible in the data. - Image message shape: a user turn whose
contentis animageblock with a base64source(media_type+data), followed by the text prompt. - PDF message shape: identical, except the block is
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": …}}. PDFs emailed as receipts are handled automatically; other content types produce astatus='error'row. - No sampling params: the example sends no
temperature/top_p/ thinking config — the minimal request shape works across current models.
Staying inside the run timeout
A Python run has a wall-clock timeout. Each receipt is one vision call of a few seconds; the example runs up toMAX_WORKERS (4) calls concurrently and
caps the batch at MAX_RECEIPTS_PER_RUN (default 12). The worker also caps
total staged input bytes at ~25 MB per run.
If more receipts are staged than the cap, the overflow is skipped this run
and the node prints a loud warning naming how many were skipped and why. Send
large batches across several emails / runs — with append + dedup configured,
the rows accumulate and nothing is double-counted.
Testing it
- Without a network or a stack —
extract_receipts_test.pyfeeds the example’s parse/flatten logic canned Anthropic responses built from a ground-truth fixture and asserts the output rows match — including the versioning columns, the PDFdocumentblock shape, and the error-isolation path (one bad response among good ones yields one error row and N−1 ok rows). Run it withpython3 -m pytest examples/receipt-extraction/extract_receipts_test.py -q. - Live, end-to-end —
manual_qa_e2e.pyemails the demo receipts to your email node and asserts the extracted vendor/total per receipt against ground truth (queryingstatus = 'ok'rows). It needs a running stack and a real API key, so it is gated behindPANELS_RECEIPT_E2E=1and skips otherwise.