DEV Community

Cover image for Shoebox: a small model reads your receipts overnight, slowly, and never guesses a number
Kai Chen
Kai Chen

Posted on

Shoebox: a small model reads your receipts overnight, slowly, and never guesses a number

Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady Submission 🦥

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady

What I Built

Receipts are the paperwork nobody does on time. They go into a wallet, then a drawer, then a shoebox, and at the end of the month (or the end of the tax year) someone sits down and types them into a spreadsheet one by one: date, shop, total, category, then files the paper or the photo somewhere it can be found again. Every freelancer and small-business owner knows the evening it takes.

Shoebox is a folder that does that evening for you while you sleep. You photograph receipts whenever you get them and let the photos land in Shoebox/Inbox (phone photo sync, a shared drive, anything). Every night at two, Shoebox wakes up and works through the new ones, one at a time, on your own computer:

  • Gemma 4 looks at each photo and copies down what is printed: every line, the subtotal, discount, tax, total, cash paid and change.
  • The arithmetic decides whether to believe it. A receipt is filed only when its total is confirmed by other figures printed on the same receipt.
  • Each believed receipt is renamed (2026-10-14 Bakery Ana 60000.jpg), filed under its month and category, and added to the month's expense sheet.
  • A receipt whose figures will not agree is never guessed at. It goes to Check me/ with a note saying exactly what disagreed.

In the morning there is one file waiting, Good morning.txt:

Filed 7 receipts overnight; 3 would not add up and wait in “Check me”.
This month so far: 756,127 (Books/expenses-2026-10.csv).
Enter fullscreen mode Exit fullscreen mode

And if you want to know something, you ask in plain words:

$ shoebox ask "How much did I spend on food and drink this month, and what is waiting for me to check?"
In October 2026, you spent 724,627 on Food & drink. Three receipts are waiting for you to check, with reasons provided for each one.
Enter fullscreen mode Exit fullscreen mode

It is slow on purpose. It runs a 4-billion-parameter model on the CPU, so it can share a laptop with everything else you do, and because it has all night it can afford to look twice. That is the trade this week's theme asked for: nothing about it is fast, and you never have to think about it.

Demo

Ten receipt photos from a month, dropped into the inbox (they are from the CORD dataset, so they are Indonesian and in rupiah). After one night:

Shoebox/
  Inbox/  (empty)
  Filed/2026-10/Food & drink/
    2026-10-04 ตั้ง 24000.png
    2026-10-06 Unknown shop 148127.png
    2026-10-07 Unknown shop 93500.png
    2026-10-09 Unknown shop 54000.png
    2026-10-10 Unknown shop 40000.png
    2026-10-17 Unknown shop 365000.png
  Filed/2026-10/Other/
    2026-10-01 Unknown shop 31500.png
  Check me/
    2026-10-03 Unknown shop 113886.png  (+ .txt)
    2026-10-13 Unknown shop 60394.png   (+ .txt)
    2026-10-17 Snack Lestari All 48000.png  (+ .txt)
  Books/
    expenses-2026-10.csv, summary-2026-10.html, ledger.jsonl
  Good morning.txt
Enter fullscreen mode Exit fullscreen mode

(Most CORD receipts have the shop's name blanked out for privacy, hence "Unknown shop"; one name came back in Thai from a logo.) Every file also gets a .json beside it with everything the model copied and every check that was made.

The month's summary, written for a person to read or print:

The October expense summary: 756,127 spent across 7 filed receipts, totals by category, the list of receipts, and three receipts waiting in Check me, each with the figures that disagreed

And this is what a receipt that will not add up looks like in the morning. The real total of this one is 68,000; the model read 48,000 the first time:

The figures on this receipt did not agree, so it was not filed:
- the two readings of the total disagree (48000 and 100000)
- paid 100000 minus change 32000 is not the total 48000

Retake the photo (flat, the whole receipt, the totals sharp), or file it by hand.
Enter fullscreen mode Exit fullscreen mode

Code

GitHub logo luoy16002-svg / shoebox

A shoebox for receipts that empties itself overnight: Gemma 4 reads them on your own computer, Temporal keeps the night shift going, Mastra keeps the books.

Shoebox

A shoebox for receipts that empties itself overnight.

Photograph receipts whenever you get them and let the photos land in one folder (your phone's photo sync, a shared drive, anything). Every night Shoebox reads the new ones on your own computer with Gemma 4, checks each one's arithmetic, files it by month and category with a sensible name, and writes the month's expense sheet. In the morning there is a two-line note:

Filed 7 receipts overnight; 3 would not add up and wait in “Check me”
This month so far: 756,127 (Books/expenses-2026-10.csv).

Nothing is uploaded anywhere. The model runs locally, the night shift survives the laptop sleeping or the process being killed halfway (it is a Temporal workflow), and you can ask about the books in plain words:

$ shoebox ask "How much did I spend on food and drink this month, and what is waiting for me to
…

How I Built It

Three open pieces, each doing one job.

Gemma 4 copies; it never calculates

The reader is Gemma 4 E4B (Apache 2.0) in llama.cpp, quantized to Q4_0 and running on six CPU threads. The prompt asks it to copy every money line exactly as printed, separators and all ("60.000", "Rp. 91,000", "12.50"), and the request carries a JSON schema that llama.cpp turns into a grammar, so the answer always parses.

Copying instead of calculating matters more than it sounds. Receipts from the test set I used print "60.000" for sixty thousand rupiah, "26,818" for twenty-six thousand, and "Rp. 91,000" with a dot that belongs to the currency. If the model normalised those numbers it would make a different mistake on each format. Instead the code reads the strings: a separator followed by exactly three digits groups thousands, a single one followed by one or two digits marks cents, and anything stuck to the currency sign is ignored. That parser has unit tests; the model never has to know any of it.

The first version had thinking turned on (Gemma 4 thinks by default in llama.cpp's chat template). It wrote a long "Here's a thinking process..." before every receipt, took about 75 seconds each, and on one receipt spent its whole token budget thinking and returned nothing at all. Copying a receipt needs eyes, not deliberation: with --reasoning off one look takes a median of 15 seconds, five times faster, and the numbers below are all measured that way.

The arithmetic decides, and the receipt is read twice

Every receipt prints its own proof. The lines add up to the subtotal. The subtotal, with discount, tax (included in the prices or added on top) and service, makes the total. The cash handed over minus the change is the total. Shoebox checks every one of these it can make.

Getting from there to a rule I trust took three tries, and I measured each one on the 95 receipts in the CORD v2 test split (real Indonesian receipts, CC BY 4.0) that print a total.

Try one: every figure must agree. On the first night it set aside four of five receipts, and all four had the right total. The model had copied the TOTAL and CHANGE lines into the item list, or written a ticket's unit price where the line meant quantity times price, so the lines never added up even though the total was fine. Too strict to be useful.

Try two: the total must be confirmed by something else on the receipt. Cash minus change, or the lines, or the subtotal. Lines that are obviously totals or payments ("TL", "CG", "TUNAI", "KEMBALI") are taken out before adding, and a line can count as quantity times price. This filed 87 of the 95 receipts, and 4 of those had the wrong total. Those were the interesting ones, because the arithmetic agreed with itself:

  • the model missed some lines, then wrote a subtotal and a total that matched the lines it had seen;
  • a shop printed "Rp 165000.00" and the model copied every amount on the receipt without the decimal point, so the lines, the subtotal, the total, the cash and the change were all ten times too big and all agreed;
  • the total and the change were misread by the same 5,000, so cash minus change still came out right.

A model can be consistently wrong, and arithmetic on its own output cannot see it.

Try three: read it twice. Now every receipt gets two looks. The first reads the whole receipt; the second sees only the bottom of the photo, cropped and enlarged, and copies the totals block. Like double entry, the two readings must give the same total. When they do, they are merged (a tax line the first look missed comes from the second) and the arithmetic decides. When they do not, a reading is used only if cash and change confirm it, because those are two figures printed apart from the total; otherwise the receipt goes to Check me/.

one look two looks
Total read correctly 88 of 95 88 of 95
Filed 87 80
Filed with a wrong total 4 1
Set aside in Check me/ 8 15
... of which the total was in fact right 5 9

The full table, with every receipt that was not filed and why, is in eval/RESULTS.md, and npx tsx eval/cord.ts reproduces it.

The row that matters is the bold one. A receipt filed with a wrong total is a silent error in your books; a receipt in Check me/ costs you ten seconds in the morning. Reading twice cut the silent errors from four to one, at the price of seven more receipts to glance at. The one that still slips through is a receipt that prints its total before rounding (680,339) and after (680,500); both looks copied the first, and the arithmetic agrees with it. The two looks together take a median of 20 seconds per receipt on six CPU threads.

Temporal keeps the night shift going

A night shift on a laptop gets interrupted: the lid closes, Windows restarts for an update, the process dies. The shift is a Temporal workflow so none of that matters.

  • A schedule starts the nightShift workflow every night at 02:00 (shoebox start --at 02:00).
  • nightShift lists the inbox and starts one child workflow per receipt, one after another, so the CPU is never asked for two at once.
  • Reading a receipt is an activity that sends a heartbeat every ten seconds while Gemma works. The heartbeat timeout is one minute, so if the worker dies mid-receipt, Temporal notices within a minute and retries the read with backoff when the worker comes back.
  • Filing checks the ledger before it moves anything, so a retry after a crash never files a receipt twice.

I tested it the rude way: started a shift, killed the worker in the middle of a receipt, waited, and started it again. At 00:23:22 UTC the fourth receipt's read started. At 00:23:30 I killed the worker. For a minute and a half nothing happened: no worker, the shift's state sitting in the Temporal server. At 00:25:13 I started the worker again. Temporal had already marked the read as failed by heartbeat timeout, scheduled it again, and the new worker picked it up. The receipt's event history (temporal workflow show -o json), trimmed to the fields that matter:

event 5  ActivityTaskScheduled  00:23:22  readReceiptActivity
event 6  ActivityTaskStarted    00:25:16  attempt 2
         last failure: activity Heartbeat timeout
event 7  ActivityTaskCompleted  00:25:45
Enter fullscreen mode Exit fullscreen mode

The shift carried on and finished the other six receipts. The ledger has every receipt exactly once. Here is the shift in the Temporal UI; the long bar in the middle is the receipt that was being read when the worker died:

The night shift in the Temporal web UI: a timeline of ten fileReceipt child workflows, each about twenty seconds long, except the fourth, which stretches over two and a half minutes across the crash and the restart

The whole Temporal server is the open-source dev server (temporal server start-dev) with its history in a SQLite file in your home folder. Nothing about it needs a cloud account.

Mastra keeps the books

Two small agents run on the same local Gemma through Mastra, pointed at llama.cpp's OpenAI-compatible endpoint:

  • The filing clerk puts each believed receipt in one of eight categories, using Mastra's structured output with an enum, so it cannot invent a ninth.
  • The bookkeeper answers questions about the ledger. It has three tools (list expenses by month, category or shop; total a month by category or shop; list what is waiting in Check me/) and instructions to take every number from a tool. The model chooses which tool to call and phrases the answer; the amounts come from the books.

Why Does Open Innovation Matter?

A shoebox of receipts is a diary. It says where you were, at what time, what you ate, which pharmacy you went to, and often the last four digits of your card. Most receipt apps that do this job today work by uploading the photos to their servers and charging a monthly fee per user. Shoebox does the same job with the photos never leaving the folder they were taken into.

That is only possible because the model's weights are open. Gemma 4 E4B is small enough to run on a CPU, licensed so anyone can, and good enough at reading print that the arithmetic around it can catch the rest. Llama.cpp makes it a local server in one command; Temporal and Mastra are open source and run on the same machine. There is no API key anywhere in the project, no bill that grows with the number of receipts, and nothing to stop working when a vendor changes its pricing.

Open weights also made the design possible. Because the model is mine to run, I could turn its thinking off, constrain its output with a grammar, give it all night, and ask it to look twice. None of those are knobs a per-request API makes cheap.

The Lazy Payoff

What it takes from you: photos dropped in a folder whenever you get a receipt, and a look at Check me/ in the morning. What it takes from the computer: about 20 seconds of CPU per receipt at two in the morning, so a month of forty receipts is a quarter of an hour while you sleep.

What it saves is the evening at the end of the month: typing each receipt's date, shop, total and category into a spreadsheet, renaming the photo and putting it somewhere you will find it again. On this test set, five out of six receipts never need you at all, and the sixth comes with a note that says exactly which figures to look at.

Prize Categories

  • Best Use of Gemma: Gemma 4 E4B reads every receipt and powers both agents, locally on the CPU through llama.cpp.
  • Best Use of Temporal: the night shift is a scheduled Temporal workflow with a child workflow per receipt, heartbeating activities and idempotent filing; killed mid-shift, it picks up where it stopped.
  • Best Use of Mastra: the filing clerk (structured output) and the bookkeeper (tools over the ledger) are Mastra agents on the local model.

Top comments (0)