![]()
Receiptee finds the payments on my company's business account that miss a receipt, matches each one with an invoice file I already have, and uploads the file to the bank after I approve the match. Nothing uploads without me. Not one file.
I built it with Claude Code on 25th and 26th September 2026, and moved it into my lab of personal tools on 28th September.
Why I built it
My company's bank keeps a list of payments without a receipt. My accountant needs a receipt for each one. The receipts mostly exist already. They sit in folders on my Mac, or as PDFs in my company mailbox, often with a date and an amount in the file name.
The work was never finding a receipt. It was the matching: open the list, pick a payment, search for the file, compare the amount and the date, upload, repeat. It's dull, it's slow, and it's exactly the kind of task that piles up.
What I didn't want was a tool that uploads things on its own. A wrong receipt on a payment is worse than a missing one. So the design rule came first: the machine suggests, I decide.
How it works
| Step | Who | What happens |
|---|---|---|
| Pull | Claude | Reads every payment without a receipt through the bank's MCP connector |
| Fetch | Receiptee | Saves new invoices from the company mailbox into my receipt folders, as new files |
| Index | Receiptee | Reads the receipt folders: file names, dates, amounts and the PDF text |
| Match | Receiptee | Ranks up to 6 candidate files per missing receipt |
| Approve | Me | I review each match in a local dashboard, with a preview of the file |
| Upload | Receiptee, on my click | Sends each approved file to the bank and attaches it to its payment |
Claude does the first step because the bank's MCP connector lives in Claude Code, and a Python script can't use Claude Code's connection. Python does everything after it.
The dashboard
The dashboard is a local web page in my NicAI design, with a preview of every candidate file. It's built for the keyboard:
| Key | Does |
|---|---|
1 - 6 |
Approves that candidate |
N |
None fits |
S |
No receipt needed |
U |
Undo |
J / K |
Next and previous payment |
Esc |
Closes the preview |
When I'm done, I click Upload and confirm. A background thread takes the approved items 1 at a time, and the dashboard shows the progress and any error.
How matching works
Each candidate file gets a score from 3 signals: my company's own bookkeeping records, the file name, and the text inside the PDF.
| Signal | Points |
|---|---|
| Amount | +50 from the bookkeeping, +45 in the file name, +35 in the PDF text, +15 within 2% |
| Date | +25 within 4 days, +15 within 12 days, -40 if more than 45 days away |
| Vendor | +25 same vendor rule, +20 the payee's name in the file, -25 another vendor's file |
A file needs 45 points to be a candidate. The dashboard labels each one High (80+), Medium (60+) or Low. The 2% band catches payments in a foreign currency.
The amount carries the most weight on purpose. An early version let a file pass on date and vendor alone, and it matched a small monthly charge with a much larger invoice from the same vendor. Now a readable file without the right amount can't reach the threshold.
Some invoices need a special rule:
- Password-protected PDFs have no readable text and no preview, but they still match on date and vendor, and they upload normally.
- A contract with monthly instalments and no monthly invoice counts as the standing receipt for each instalment.
- Emails without a PDF get printed to PDF with headless Chrome.
Nothing uploads without me
This is the part I care about most, so it gets its own section.
- Only I approve a match, in the dashboard. No CLI command approves anything, and no agent can.
- An approval covers 1 file on 1 payment. Nothing else.
- Upload is a destructive action. The CLI refuses it without
--yes, and offers--dry-runto show what would go. - Receiptee has its own connection to the bank, separate from Claude's. It can call exactly 2 tools: request an upload, and attach a file to a payment. The allowed list is in the code, and it doesn't grow without my explicit instruction.
- The token lives in the macOS Keychain. Upload links and tokens are never printed, logged or saved.
- The mailbox is read-only. Receiptee never marks, moves or deletes an email.
- My receipt folders are add-only. A fetched invoice is saved as a new file with exclusive create, so it can't overwrite anything. If the same bytes or the same text already exist, it skips the file.
At the time of writing, the matching runs and the bank connection works, but I haven't approved the first batch yet. The upload path waits for me, which is the point.
How it's built
- Python standard library, about 2,000 lines with the dashboard, plus
pdftotextandpdftoppmfor the text and the previews. SQLite holds the payments, the file index, the matches and my decisions. - A local web server for the dashboard, run by Portee (see Portee: one page for all my local apps).
- A small MCP client of its own for the uploads: it registers with the bank's connector, sends me to the bank's consent screen once, and stores the token in the Keychain.
- A vendor file with the scan folders, the vendor rules and a fetch profile for each recurring supplier.
The agent interface
Like my other apps, Receiptee has a CLI that NicAI can drive, linked at ~/.local/bin/receiptee. Every command takes --json and prints 1 JSON document:
receiptee status --json # missing receipts, candidates, approved, uploaded
receiptee index --json && receiptee match --json
receiptee upload --dry-run --json # what the dashboard would upload; --yes starts it
A QA script tests the contract on a scratch database and port. It never uploads, never reads the mailbox and never touches the Keychain.
Gotchas
- Claude Code's auto mode said no, twice. My first design was a headless Claude worker that uploads the files. The auto-mode safety check blocked it as an unsafe agent, then blocked the first version of the stored-credential code too. Both calls were fair. I reviewed the design myself, chose the version with its own connection and 2 allowed tools, and approved it outside auto mode.
- A blocked user agent. The connector's registration endpoint refused Python's default
Python-urllibuser agent with a Cloudflare error. A customUser-Agentfixed it. - Only leading dates count. I name files with a
YYMMDDprefix. The first version read any 6-digit number as a date, so invoice numbers became dates. Now only a leadingYYMMDDcounts. - Huge tool results. A page of 100 transactions from the connector can run to 475,000 characters. Claude saves it to a file, and Python parses the file.
- Files that rename themselves. A file automation renames new files a few seconds after they arrive, so an index run can miss a file it just saw. Running it again fixes it.
How it was built
- 25th September 2026: a Claude skill to read the bank account, then the first Receiptee, built as a skill with a Python script and a dashboard.
- 26th September: the mailbox fetch for recurring suppliers, the upload design with its own bank connection, and the dashboard in my NicAI design.
- 28th September: moved into
~/ai/lab/receiptee/with my other tools. - 24th October: the agent interface, and the code went into a private git repo.
All my apps: see My apps: everything I built for myself. Why I build my own tools: see The era of personal software is here.
Further reading
The tool I built it with:

The protocol behind the bank connection:

- What is NicAI: the assistant behind all my tools.
- Portee: one page for all my local apps: the local dashboard that runs Receiptee.