![]()
Scanee turns a button press on my scanner into a filed, named and searchable document. I put paper in, press 1 button, and a minute or 2 later a notification tells me the title of the document and the folder it went to.
Everything runs at home. The scanner drops a PDF on my NAS, my Mac Studio does the OCR, a local model reads the text and picks the folder, and the file lands in an archive with a full-text index. No cloud OCR, no upload, no subscription.
I built it with Claude Code on the weekend of 26th and 27th September 2026. The archive now holds about 1,200 documents, and my NicAI agents search it whenever a task needs a paper document. The name fits the -ee family of my other apps, next to Lastee: my own recent-files app for macOS and Filee: my own file manager for macOS.
Why I built it
My scanner can push a PDF to a network folder at the press of a button. That's where its help ends. Every scan arrives as a file named after the time of the scan, such as 20260926143012.pdf. A folder of those is a pile, not an archive: each file needs someone to open it, read it, rename it and move it.
What I wanted:
- 1 button, no screen. No app to open, no dialog, no naming.
- Searchable text in 3 languages. My papers come in German, French and English.
- A folder I don't pick myself, from a fixed list, so the archive stays tidy.
- Nothing leaves the house. These are the family's papers.
- Findable by an agent. NicAI should answer "where's the letter about X?" without me digging.
What it does
scanner ── PDF ──► NAS inbox
│
Mac Studio (launchd agent, checks every 2 s while the scanner is on)
1. OCRmyPDF in German, French and English, if the text layer is missing or broken
2. a vision model describes the page when OCR finds nothing (handwriting, sketches)
3. a local LLM picks the folder and extracts date, sender, type, title, summary
4. files it as YYMMDD-Sender-Type.pdf in its folder
5. moves the raw scan to an originals folder, by month
6. rewrites the index: Markdown, JSON Lines, a SQLite snapshot, 1 text file per document
7. shows a macOS notification with the title and the folder
The file name follows the same rule as everything else I file: the document's own date as YYMMDD, the sender's short name and a 1-word German document type, such as Rechnung for an invoice.
The rules around it:
| Case | What Scanee does |
|---|---|
| No readable text, or folder confidence < 0.5 | Moves it to a review folder for me |
| Text came from the vision model | Files it, but flags it for review |
| Same file twice (same SHA-256) | Files it once |
| A file fails 3 times | Moves it to a "failed" folder |
| A Word, RTF, HTML, email or Excel file | Files it in its own format, text read without OCR |
The vision model always triggers the review flag for a reason: on a sketch, it invents details.
Search
Every document goes into a SQLite FTS5 index: title, sender, type, summary, tags, reference numbers and the full OCR text. When a task needs a paper document, NicAI searches that index and reads the full text of the match.
scanee search "rechnung*" --limit 5 --json # FTS5 query: a JSON array of matches
scanee show <id> --json # 1 document: metadata and the full text
scanee status --json # watcher running, counts only, last error
scanee process scan.pdf --dry-run --json # the folder and file name it would get
status prints counts only and never mounts the share. Reads open the database read-only. Every command takes --json, and errors carry ok: false, an error and a hint, like every other app in the family.
The local AI
| Step | Tool | Measured on the M1 Ultra |
|---|---|---|
| OCR | OCRmyPDF with Tesseract, 3 languages | About 20 s per page |
| Classification | Mistral Small on Ollama | 20 to 30 s per document |
| Vision | Gemma 3 4B on Ollama | About 13 s per page |
A scan that already has a good text layer skips OCR.
2 decisions keep the classification reliable:
- The folder list is closed. 43 folders, each with a 1-line description that steers the model. The model's output is constrained by a JSON schema, so it can only return a folder from the list. An answer outside the list goes to "Other" with a confidence of 0.
- Temperature 0, and a short context file. A few household facts help the model, and the folder descriptions do the rest. To add a folder, I add 1 line to the list, and it exists from the next scan.
Alerts
A pipeline that runs in the background fails in silence unless it tells me. Scanee shows a banner and a dialog with the problem and the steps to fix it. The same problem alerts at most once an hour, and a "fixed" banner follows when it's solved.
| Problem | How Scanee finds it |
|---|---|
| A scan failed on the scanner (never arrives) | Reads the scanner's job log every 60 s |
| The share isn't mounted | Checks it on every loop |
| macOS blocks the share after an upgrade | A permission error on the inbox |
| Ollama isn't running | Checks its API, and starts the app first |
| OCR or classification failed | The result of each scan |
A scan filed with a review flag gets a "check this scan" banner, not a dialog.
What I learned
- Some scanners have no API at all. Mine has no macOS driver over the network, no AirScan and no documented API. Its own "send to network folder" job is the only push-button path. To read its job log, Scanee copies the calls its web admin page makes, read-only.
- A scanner without a clock refuses to scan. Every job failed until I set its time server. It had to be an IP address: a name didn't work.
- Don't poll a device that's asleep. The scanner switches itself off after 20 minutes. Scanee tests it every 15 s: while it's on, the inbox check runs every 2 s, and while it's off, there's no inbox check at all. The arrival banner shows 9 to 11 s after the button press.
- macOS privacy settings key on the Python binary path. A background process gets no prompt: macOS denies network volumes in silence. The permission is tied to the exact Homebrew Python path, so every Python upgrade needs a new grant. Scanee now detects it and says so.
- Give the pipeline its own account. Without a saved login, the watcher asked for a password every minute after a restart. Scanee now mounts the share with an account that can reach that 1 share and nothing else.
- Write, then rename. Scanee never writes to the share directly. It writes a hidden
.partfile, then renames it, so a half-copied file never shows up in the archive.
How I built it
| Date | Step |
|---|---|
| 26th September | Scanner set up, first scan filed, an older folder of scans imported |
| 27th September | The 2-speed watcher: the banner shows 9 to 11 s after the button press |
| 30th September | Its own share account, after the password-prompt loop |
| 24th October | The agent interface (--json on every command), tests and a private repo |
The first import shows what it does on a backlog: 294 files in 2 h 35 min, with 270 filed, 20 sent to review, 4 duplicates and 0 errors. The source folder stayed untouched.
The whole thing is 1 Python script, about 1,460 lines, standard library only, plus ocrmypdf, poppler and Ollama. 7 unit tests run on a scratch database and a scratch folder, never on the real archive.
Familee, my family app, reads the archive too: a daily job matches scanned documents to home upkeep items, see Familee: my own family app for iPhone.
Further reading
The OCR layer:

The OCR engine under it:

Where the 2 models run:

The tool I built it with:

- Ollama Python Library: my notes on Ollama from Python.
- What is NicAI: the agents that search the archive.
- The era of personal software is here: why I now build the apps I use.
- All my apps: see My apps: everything I built for myself