Extract invoice data into a spreadsheet¶
Build an agent that reads a batch of invoices and writes one CSV with the same columns for every row. The fixture is three small synthetic invoices, one of them missing its date, so the check that matters is whether the agent reports the gap instead of inventing a plausible-looking one. This is a procedure to run, with one recorded run as a reference.
What you need¶
- A running installation with a model profile and a
registered sandbox connection offering the
workbenchruntime, which carriesliteparse(lit) andpdftotextfor reading PDFs and a Python environment for writing the CSV. - No knowledge, no charts.
Prepare the input¶
Three short invoices, generated as PDFs so the fixture matches what a real
upload looks like. Save this script as make_invoices.py in a scratch
directory:
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
def draw_invoice(path, number, date, vendor, lines, tax_rate, include_date=True):
c = canvas.Canvas(path, pagesize=A4)
width, height = A4
y = height - 30 * mm
c.setFont("Helvetica-Bold", 16)
c.drawString(20 * mm, y, "INVOICE")
y -= 10 * mm
c.setFont("Helvetica", 11)
c.drawString(20 * mm, y, f"Invoice number: {number}")
y -= 6 * mm
if include_date:
c.drawString(20 * mm, y, f"Date: {date}")
y -= 6 * mm
c.drawString(20 * mm, y, f"Vendor: {vendor}")
y -= 6 * mm
c.drawString(20 * mm, y, "Bill to: Meridian Analytics BV")
y -= 12 * mm
c.setFont("Helvetica-Bold", 11)
c.drawString(20 * mm, y, "Description")
c.drawString(130 * mm, y, "Qty")
c.drawString(150 * mm, y, "Amount")
y -= 6 * mm
c.setFont("Helvetica", 11)
subtotal = 0.0
for desc, qty, amount in lines:
c.drawString(20 * mm, y, desc)
c.drawString(130 * mm, y, str(qty))
c.drawString(150 * mm, y, f"{amount:.2f}")
subtotal += amount
y -= 6 * mm
y -= 4 * mm
tax = round(subtotal * tax_rate, 2)
total = round(subtotal + tax, 2)
c.drawString(120 * mm, y, "Subtotal:")
c.drawString(150 * mm, y, f"{subtotal:.2f} EUR")
y -= 6 * mm
c.drawString(120 * mm, y, f"Tax ({int(tax_rate*100)}%):")
c.drawString(150 * mm, y, f"{tax:.2f} EUR")
y -= 6 * mm
c.setFont("Helvetica-Bold", 11)
c.drawString(120 * mm, y, "Total:")
c.drawString(150 * mm, y, f"{total:.2f} EUR")
c.showPage()
c.save()
return subtotal, tax, total
s1 = draw_invoice("INV-1001.pdf", "INV-1001", "2027-01-15", "Nordic Office Supplies",
[("Desk chairs, ergonomic", 3, 420.00), ("Standing desks", 2, 260.00)], 0.21)
print("INV-1001", s1)
s2 = draw_invoice("INV-1002.pdf", "INV-1002", "2027-01-22", "Blue Ridge Logistics",
[("Freight, Rotterdam-Warsaw", 1, 780.00), ("Customs handling", 1, 195.00)], 0.21)
print("INV-1002", s2)
s3 = draw_invoice("INV-1003.pdf", "INV-1003", None, "Summit Cleaning Services",
[("Monthly office cleaning, January", 1, 227.27)], 0.21, include_date=False)
print("INV-1003", s3)
Then run it there. It writes INV-1001.pdf, INV-1002.pdf and
INV-1003.pdf beside itself:
make_invoices.py draws each one with reportlab: an invoice number, a
vendor, a line-item table, a subtotal, 21% tax and a total — INV-1001 and
INV-1002 complete, INV-1003 with no date line at all, on purpose.
The reference values, to check the extraction against:
| Invoice | Date | Vendor | Subtotal | Tax | Total |
|---|---|---|---|---|---|
| INV-1001 | 2027-01-15 | Nordic Office Supplies | 680.00 | 142.80 | 822.80 |
| INV-1002 | 2027-01-22 | Blue Ridge Logistics | 975.00 | 204.75 | 1179.75 |
| INV-1003 | (missing) | Summit Cleaning Services | 227.27 | 47.73 | 275.00 |
Every total is subtotal plus 21% tax, so a wrong total is checkable by hand.
Build the agent¶
- Create an agent in Agents → New agent and select your model profile.
- In Toolbox, enable Files & shell. Choose Container, select
your sandbox connection and the
workbenchruntime. - Set a budget and a step limit — reading three short PDFs and writing one CSV is a handful of tool calls.
- Set the instructions below, then Publish.
You extract structured data from attached invoices.
Read each invoice from the workspace before extracting anything - use lit
or pdftotext to get its text; do not guess from the filename.
Write invoices.csv in the workspace with exactly these columns: invoice_number,
date, vendor, subtotal, tax, total, currency.
If a field is not present on an invoice, leave that cell empty and name the
invoice and the missing field in your reply. Never invent a value that is
not on the document.
Check that subtotal plus tax equals total for each invoice, and say so if one
does not.
Run it¶
Attach INV-1001.pdf, INV-1002.pdf and INV-1003.pdf to a new
conversation and send:
Reading a PDF's text on the workbench runtime runs lit or pdftotext
through execute, which asks for approval the same way a shell command does
in Turn a CSV into a chart you can check. Read the
command, then Approve; writing the CSV itself needs no approval. The
agent may park on execute more than once in the same turn — a first
attempt that writes lit's output to a file outside the workspace, then a
second that prints straight to the command's own output, is one shape this
can take. See the recorded run below.
Check the result¶
| Check | Reference |
|---|---|
invoices.csv rows |
Three, one per invoice, same seven columns in the same order |
| INV-1001 and INV-1002 | Every field matches the reference table exactly |
| INV-1003's date cell | Empty, not a guessed or invented date |
| The reply | Names INV-1003 and "date" as missing, rather than only leaving the cell blank |
| Totals | Subtotal plus tax equals total on every row; the agent says so if it checked and one doesn't |
| A fourth file that is not an invoice at all | The agent says it could not extract invoice fields from it, rather than inventing a row |
Open invoices.csv from the workspace and check it directly — a reply that
lists the right numbers in prose while the file holds something else is a
gap only the file itself shows.
Recorded on v0.0.504, 25 September 2026
Model: Claude Sonnet 4.6 through OpenRouter, workbench runtime. The
agent's first execute call ran lit parse <file> -o /tmp/inv*.md for
all three PDFs, then tried read_file on /tmp/inv1001.md and the
other two — all three failed, because /tmp is outside the workspace
read_file can reach, even though execute itself can write there. The
agent recovered on its own: a second execute call re-ran lit parse
for all three files with no -o, printing straight to the command's
output, which it then read directly from the tool result. Both execute
calls needed separate approvals in the same turn.
invoices.csv came out with all three rows and the reference columns,
every INV-1001 and INV-1002 field matching the source PDFs exactly, and
INV-1003's date cell empty. The reply named "INV-1003 — date" as
the missing field. Before finishing, the agent ran its own arithmetic
check with a one-off execute — python3 -c "..." reading the CSV back
and comparing subtotal plus tax to total — and reported all three rows
balanced. Total cost for the run: 0.1302 USD, on 37,246 input and 1,233
output tokens.
When it goes wrong¶
- The agent guesses a date for INV-1003. The instructions say to leave the cell empty and name the gap; if it still fills one in, add "do not infer a date from context" and test again with the same file.
- The run stops after reading the files. It is parked on the
executeapproval forlitorpdftotext. Open the chat, or Approvals under Activity. It can park twice in the same turn — see the recorded run above. read_filefails on a pathlitjust wrote.executecan write anywhere in the container, including outside/workspace, butread_fileis scoped to the workspace. Have the agent parse straight to the command's own output, or writelit's-ofile inside the workspace, not/tmp.- A total is off by the tax amount. Check whether the agent read the invoice's own printed total or recomputed one — a fixture this small should never need recomputing, and a mismatch usually means a misread line item.
- The CSV has different columns each time. The instructions name them exactly; if the model still varies the order or adds a column, list them as a literal header line rather than prose.
- A PDF with a scanned page reads as empty.
litonly OCRs a page with no text layer, and a scan costs several seconds a page — see what is in the runtime, and what is deliberately not before pointing this agent at scanned invoices rather than generated ones.
Record the trial¶
Keep the three source PDFs, the exact prompt, the agent version, the
approved execute calls from Activity, and the resulting invoices.csv —
not just the chat reply. A person still checks the missing-field report
against the source PDF and decides what to do about the gap; the agent's
job is to surface it, not resolve it.
Next steps¶
Once this holds on a batch of three, add a fourth invoice in a different currency or a different layout, one difficulty at a time, the same way Turn a CSV into a chart you can check suggests for its own fixture.