# Building an accounting skill for Claude: the check-splitter, and the method behind it

_A self-contained build guide. Written 2026-09-25 from the repo's log, reports and history;
Stages 16–17 added 2026-09-26._

This guide does two things at once. It records, stage by stage, how the **check-splitter**
skill was built, tested and corrected over four days, with the numbers. And it uses that story to
teach the _method_: how to go from a customer's remark to a skill you can trust, when you can't see
inside the model and the only way to know it works is to measure it.

You don't need the original repository. Everything needed to rebuild the pieces is here: the
schemas, the prompts, the rules, the workflow text, the results to expect at each stage, and, for
each stage, what to ask your own Claude to build. Your build will differ in its details. The
thinking should carry over.

---

## Contents

- Part 0: How to read this
- Part 1: Primer: the pieces and the words
- Part 2: The method, on one page
- Part 3: The worked example, stage by stage
  - Stage 1: The signal and the real job
  - Stage 2: Finding test material
  - Stage 3: First fixtures (and why they weren't enough)
  - Stage 4: The plan: what to vary, and what "an answer key" must contain
  - Stage 5: Building the generator
  - Stage 6: The scorer
  - Stage 7: The blind-test protocol
  - Stage 8: Baseline 1: no skill at all
  - Stage 9: Removing the shortcuts, baseline 2
  - Stage 10: The reframing: where the skill actually runs
  - Stage 11: Zoom, baseline 3: reading solved, scaling broken
  - Stage 12: Batching and triage, baseline 4
  - Stage 13: Legibility, the detector, workflow v0, baseline 5
  - Stage 14: Workflow v1, the held-out test, the first package
  - Stage 15: Real sessions: runs A to D, and skill v0.3 to v0.5
  - Stage 16: Output polish: designing for the first ten seconds (v0.6)
  - Stage 17: One workbook that stands alone (v0.7)
- Part 4: The skill as it stands
- Part 5: Heuristics: what to carry to the next skill
- Part 6: Open items
- Appendix A: The answer-key schema
- Appendix B: The blind-test prompt
- Appendix C: The delegation worker brief
- Appendix D: Results ledger
- Appendix E: SKILL.md, full text

---

## Part 0: How to read this

Each stage in Part 3 has the same shape:

- **What we did**, **why**, **what it showed**, **what changed as a result**.
- **Correction** boxes, wherever validation found something wrong: what happened, how it was
  noticed, what was done, and the lesson. Trivial typos are left out, unless one caused a real
  effect. Several did.
- **Ask your Claude:** a short brief you could give a Claude session (Cowork, Claude Code, or
  chat with code execution) to build that stage's piece. It's a starting point, not a script.
- **Expect:** the numbers a faithful rebuild should land near.

If you're only here for the method, read Parts 1, 2 and 5, then skim the stage headings.
If you're rebuilding, Part 3 in order, with the appendices open.

A companion, `BUILD-GUIDE-message-by-message.md`, quotes every prompt the owner wrote during the
build, with what Claude took each to mean and why. Read it to see how short the prompts could be,
and what made the short ones sufficient.

---

## Part 1: Primer: the pieces and the words

Nothing here needs prior experience with Claude. These are the dozen ideas the rest relies on.

**Claude, and a session.** Claude is the model. A session (a chat) is one conversation with it. A
session has a **context window**: everything Claude can currently see, which includes your
messages, files you upload, and the results of anything it ran. When the context fills, older
material is dropped. Images are the expensive part: a session can hold on the order of **80–100
images** before earlier ones are removed ("media removed"). This limit shaped this whole project.

**Code execution.** Claude can run Python and shell commands in a sandboxed environment. That
means it can run a script you give it against a file you upload. Everything the skill does with a
PDF happens this way.

**A skill.** A folder that teaches Claude how to do one job, packaged as a `.skill` file:

- `SKILL.md`: instructions, with a `description` at the top. Claude reads the description to
  decide when the skill applies, and the body when it uses it.
- `scripts/`: programs the skill bundles (here, one Python script).
- `references/`: longer material Claude reads only when needed.

A skill runs _inside_ a normal session. It doesn't get a bigger context window or special powers.
It gets instructions and tools.

**Tools and scripts.** When Claude "zooms into a check", it isn't looking harder. It runs a script
that cuts a region out of the PDF at full resolution and saves a picture, then it views the
picture. Every capability the skill has beyond reading and writing text is a script.

**First-glance view.** When Claude views a PDF page, it sees an image of the page at a fixed
maximum size (about 1568 pixels on the long side). A page of 69 small checks becomes 69 tiny
images, each around 220×100 pixels: enough to see that it's a check, not enough to read a
handwritten amount. The script's whole purpose is to get past this.

**Subagents.** Some environments (Claude Code, Cowork) let Claude start separate Claude
sessions to do parts of a job in parallel, each with its own context window. Plain chat can't.
A skill can suggest this but can't force it.

**MCP.** A connector that gives Claude tools into another system. Legix has one. Its document tool
returns whole pages at the first-glance size and can't upload. That mattered early (Stage 10).

**Answer key / ground truth.** For every test file, a machine-readable record of the right
answers: where every check is, what it says, which statement line it matches, and what's
deliberately wrong. Without this, "it looks right" is the only evaluation, and that isn't one.

**Held-out set.** Test files nobody looks at, tunes on, or runs until the end. The score on them
is the honest one. Everything else is development.

**Blind test.** A run where the thing being tested can't see the answers, the file names, or any
hint. Here: a subagent locked into a folder of renamed files, with its every action audited
afterwards.

**Precision and recall.** Of the checks it reported, how many were real (precision)? Of the real
checks, how many did it find (recall)? Both are needed: reporting everything as a check gives
perfect recall and terrible precision.

**IoU.** How well a reported box overlaps the true box, from 0 to 1. A match at IoU ≥ 0.5 counts
as "found".

---

## Part 2: The method, on one page

This is the loop. The stages in Part 3 are one pass through it.

1. **Start from a real signal, and find the real job inside it.** "Split the checks" turned out
   to be "read and verify every check", because splitting was easy and reading wasn't. You only
   learn that by measuring.
2. **Decide where the skill will actually run** (which files it will have, which tools, which
   limits). Half of this project's early measurements were against the wrong environment.
3. **Build test material with answer keys before building anything else.** If the answers come
   from the same process that produces the files, the keys are free and exact.
4. **Make the material varied and reproducible.** Named scenarios for the cases you know
   matter, random combinations for the ones you don't. The same seed must produce the same file,
   byte for byte, or your baselines drift.
5. **Hold some of it out.** Decide before you start which files nobody touches.
6. **Write a scorer, and test the scorer** with a perfect answer and with planted errors. A
   scorer that's wrong is worse than none.
7. **Run a blind baseline with no skill.** This tells you what the model already does and
   exactly where it breaks. Don't design before this.
8. **Make the test harder until the baseline can't shortcut it.** The first baseline got 100%
   on amounts without reading a single check. Remove captions, shuffle order, plant errors that
   only reading can catch.
9. **Find the wall, and build the smallest tool that removes it.** The wall was resolution.
   The tool was a crop script. Then the wall was speed and the image limit. The tool was batching.
10. **Turn what worked in the runs into written workflow steps.** The prompt that worked _is_
    the first draft of the skill.
11. **Run the workflow on the held-out set** before calling it a skill.
12. **Run it in a real session, the way a user would.** The blind runs missed a failure mode
    that the first real run found in an hour (Stage 15). Then iterate on what real sessions show.

Three principles run under all of it:

- **Measure, don't assume.** Every design decision here followed a number.
- **An honest "couldn't read it" beats a confident guess.** For an accountant, a filled-in wrong
  amount is worse than a blank. This became the skill's first rule.
- **Keep a log.** Every stage was written up the day it happened: what, why, result, next. This
  guide was reconstructed from that log. Without it, the reasoning would be gone.

---

## Part 3: The worked example, stage by stage

### Stage 1: The signal and the real job

**What we did.** Went back to the transcript of a weekly call with a bookkeeping customer
(2026-09-10). One of their accountants described his most manual account: a client
whose bank puts every month's check images inside the statement PDF, dozens of small images per
page. He screenshots each one, reads the payee and amount, and uploads each to QuickBooks
separately. He asked whether the AI could "extract the images", and whether it could "split" a
PDF with many checks into individual documents.

**Why start here.** A skill built from an imagined need drifts. One built from a specific
person's specific pain has a definition of done: could this person stop doing that?

**What it showed.** The ask had three parts, in the order he'd feel them: get each check out as
its own image; know what each check is for; attach it to the right transaction. Two facts about
the environment also came out of the call: the customer's team worries about check images
(signatures) leaving their systems, and the volume is high (up to 69 per page).

**What changed.** The first target was set as "split a statement into per-check images", the
literal ask. It would move (Stage 8).

**Ask your Claude.** _"Here's a transcript. Extract: what the person does today step by step,
where the time goes, what they asked for in their own words, and any constraints (privacy,
volume, systems). Separate what they asked for from what would actually remove the work."_

---

### Stage 2: Finding test material

**What we did.** Looked for real examples in three places at once:

- **Inside Legix**, via its MCP, a subagent scanned every client's documents for PDFs that
  looked like statements with check images.
- **On the public web**, for sample statements and check-image datasets.
- **In the customer's own files**, by asking. (The accountant had offered his client as a test.)

**What it showed.**

- Legix had **no** statement-with-check-grid PDFs (those clients weren't onboarded). It did have
  useful _adjacent_ real files: multi-page PDFs of scanned checks, one per page; one where a
  check shares a page with a cover letter; one with a check's front, its back and an invoice.
  These were noted as a future real-world test set, not used yet.
- The web had **no** public statement with check images included (banks don't publish those;
  court exhibits are sealed). It had one sample statement with an empty "check images" section
  (Impact Bank), and several check-image datasets:

| Dataset                    | What                                         | License                     | Used?                                 |
| -------------------------- | -------------------------------------------- | --------------------------- | ------------------------------------- |
| Impact Bank sample         | 2-page statement, 9 checks listed, no images | none stated (public sample) | as one statement template             |
| HF synthetic cheques (295) | Indian cheques, composited                   | Apache 2.0                  | yes, for visual variety               |
| IDRBT (112)                | real scanned Indian cheques                  | none stated; citation asked | yes, marked so it can be filtered out |
| SSBI (4,360)               | US-style synthetic checks                    | CC BY-NC (non-commercial)   | **no**, kept for reference only       |

**Why licenses matter here.** A commercial product can't be developed on non-commercial data.
Every test file records which image sources it uses, so anything with unclear licensing can be
excluded from a run in one filter.

**What changed.** No usable real file existed, so the decision was made to **generate** test
statements. The web datasets became ingredients, not the corpus.

**Ask your Claude.** _"Search these sources for examples of [document type]. For each find:
what it contains, its license, and whether it could be used to develop a commercial product.
Report what doesn't exist too."_ Give it the negative result explicitly as a valid outcome.

**Lesson.** The absence of real data is a finding, not a blocker. It tells you to generate, and
it tells you that a real file, when one arrives, will be the most valuable test you have.

---

### Stage 3: First fixtures (and why they weren't enough)

**What we did.** In an hour, a script that took the Impact Bank sample and appended pages of
check-image grids built from the Hugging Face cheques: 6, 20 or 69 per page, three files each,
with an answer key recording every image's page and box.

**What it showed.** It produced files that _looked_ right, and it exposed the shape of the
problem: the answer key has to be generated by the same code that places the images, or you'll
be labelling by hand forever.

**Why it wasn't enough.** Every file shared the same two statement pages. The check images were
Indian cheques whose printed amounts had nothing to do with the statement. Nothing could test
_reading_ or _matching_, only "find the rectangles". A skill tuned on this would learn one layout.

**What changed.** A proper plan (Stage 4). These nine files were kept as a "legacy" set.

**Lesson.** A quick first version is worth building precisely because it shows you what the real
version needs. Don't skip it, and don't stop at it.

---

### Stage 4: The plan: what to vary, and what "an answer key" must contain

**What we did.** Wrote a plan and had it reviewed before building. The reviewer (the project
owner) made two decisions that shaped everything after:

1. **Use all three image sources**: checks drawn by the generator (the main source), plus the two
   Indian sets for visual variety.
2. **Make about 150+ files**, from named scenarios plus random combinations, with a held-out slice.

And one boundary: **no skill yet**. Only test material and a light way to score attempts.

**The variation axes.** A file is a seeded random draw from each axis. Named scenarios pin the
axes that define them and let the rest vary.

| Axis               | Values                                                                                                                                                                                           |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Statement template | 5 invented banks with different layouts (one-column with running balance; sectioned with a 3-column "Checks Paid" table; summary first or last; daily balances), plus the real Impact Bank pages |
| Size               | 1–2 statements per file, 4–290 checks, 1–10 statement pages                                                                                                                                      |
| Check images       | drawn US checks (4 styles, handwritten or printed, MICR line, signature fonts); HF cheques; IDRBT scans; mixes                                                                                   |
| Layout             | grids 2×3 to 5×14 (69/page); front+back side by side or stacked; one per page                                                                                                                    |
| Captions           | none / `#1234 $50.00` / long / `Item 17`; above or below                                                                                                                                         |
| Placement          | after the statement · starting partway down the last statement page · after each statement page · checks only · statement only                                                                   |
| Extra pages        | deposit slips in the grid, substitute-check pages, a notice, a blank page                                                                                                                        |
| Scan quality       | digital (text layer) · flattened · office scan (tilt, noise) · fax (dithered or thresholded) · phone photo (perspective, shadow)                                                                 |
| Edge cases         | a listed check with no image, a duplicate image, a VOID check, repeated amounts, out-of-sequence numbers, rotated pages                                                                          |

**Why drawn checks.** They're the only images whose fields can be graded: the generator knows
the payee, amount and date it wrote, and it writes them to match (or deliberately not match) the
statement. They also carry no license question. The Indian cheques are placed as-is and marked
`field_ground_truth: false`: their position, side and link are graded, their text isn't.

**What an answer key must contain.** This was the most important design decision of the
project, because every later measurement depends on it. The key records, per file: which pages
are statement vs. check images; every statement's full transaction list with running balance;
every check image's page, box, side, fields, the statement line it belongs to, its image source,
and flags for anything deliberately wrong; and the checks the statement lists that have no image.
The full schema is in Appendix A. Design it _before_ the generator, and add fields as tests need them
(three were added later: `posted_amount`, `unreadable_fields`, `render`).

**The scorer, planned light.** Statement pages right? Checks found (precision/recall)? Fields
right? Matched to the right line? Missing images found? One line per file. The output format it
expects was declared a placeholder, to be replaced when a skill existed.

**Ask your Claude.** _"Plan a generator for [document type] test files. List the axes of
variation a real one has. Define an answer-key schema that records everything a grader would
need, including deliberate errors. Propose named scenarios for the cases that matter most and a
random mode for the rest. Don't build yet."_ Then review the plan yourself; the axes list is
where your domain knowledge goes in.

**Lesson.** The two hours spent on the answer-key schema saved days. Everything scorable later
was scorable because the key already held it.

---

### Stage 5: Building the generator

**What we did.** A Python package (about 1,500 lines) with one module per concern:

| Module      | Job                                                                                                                                                                                                                                                                                                                                                                            |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ledger`    | invented banks and account holders; one statement period's transactions with a running balance that adds up; checks as transactions carrying the fields to draw; a `posting_error()` helper (transposed digits, shifted decimal, cents slip)                                                                                                                                   |
| `statement` | lays statement pages out as a display list (text, lines, rectangles) in two layouts, with the summary first or last, a column-major Checks Paid table, notice and blank pages                                                                                                                                                                                                  |
| `checks`    | draws a US check front (payer block, bank, number, date, payee, figure box, amount in words, memo, signature, MICR line) in 4 visual styles, handwritten or printed; draws backs (endorsement, bank stamp); deposit slips; a VOID stamp; loads the Indian sets                                                                                                                 |
| `degrade`   | per-check effects (grayscale, noise, blur by a roughness knob) and per-page effects (office-scan tilt and noise; fax down-sample and dither/threshold; phone-photo perspective warp, shadow and colour cast; 90° page rotations). Every page effect also returns a function mapping any point through the same geometry, so **the answer-key boxes follow the pixels exactly** |
| `layout`    | grid and pair layouts, captions, placing images into cells with rotation and jitter, filling the bottom of a statement page with checks, page headers                                                                                                                                                                                                                          |
| `render`    | one page model rendered two ways: real PDF text via reportlab (digital pages) or pixels via Pillow (anything scanned); writes the PDF and a ≤1568px preview of every page (what Claude sees at first glance)                                                                                                                                                                   |
| `compose`   | turns a sampled configuration into the ordered page list: 1–2 statements, image units, missing/substitute/duplicate/VOID/not-on-statement checks, deposit slips, placement, rotations                                                                                                                                                                                          |
| `scenarios` | the axis sampler and the named scenarios (22 at first, 28 later)                                                                                                                                                                                                                                                                                                               |
| `generate`  | ids, dev/holdout split (seed 4 of each scenario plus 20% of random files held out), the answer key, `index.csv`                                                                                                                                                                                                                                                                |

Plus `validate.py` (does every answer key agree with its PDF: page counts, boxes inside pages and
not overlapping, every listed check either imaged or declared missing, backs paired to fronts,
balances adding up), and `contact_sheet.py` (one image per six scenarios with the answer-key
boxes drawn on, so a person can check the layout by eye).

**What it produced.** 178 files (22 scenarios × 4 seeds + 90 random), 178/178 valid, a perfect
attempt scoring 100% on all of them. Fonts came from the operating system; the only install was
reportlab.

**Corrections during the build** (each found by looking at contact sheets or spot-checking):

> **Phone photos had horizontal stripes.** The shadow's strength was being re-randomized for every
> gray level inside a per-pixel function. Fix: draw the random value once, outside. _Lesson:
> randomness inside a per-pixel callback is almost always a bug; look at the output._

> **Fax pages lost almost all check detail.** A hard black-and-white threshold erased the light
> strokes. Fix: dither most fax pages (the way fax "photo mode" does), threshold the rest more
> gently. _Lesson: an effect that's too strong is as unrealistic as none._

> **Captions floated in the middle of cells, far from their check.** Fix: sit each image against
> its caption. Cosmetic, but a model reading "which caption belongs to which check" would have
> been confused by our layout, not by real ones.

> **A scenario asked for more checks than transactions.** The transaction count is now at least
> checks + 6. _Lesson: named scenarios override sampled values; check the overrides don't
> contradict each other._

> **Named scenarios sometimes drew "statement only" and lost the check images they existed to
> test.** Placement is restricted for named scenarios. Same lesson.

**Reproducibility, which took two rounds.** The intent was: same id, same file. Round one seeded
the content, and every file's _content_ was reproducible. Round two (Stage 9) found the _bytes_
weren't: the image library's noise generator ignores seeds; the PDF writer stamps a timestamp
and a random document id; temporary file names leaked into the PDF's image names. Each was
replaced with a seeded or content-derived value. After that, two runs produce byte-identical
PDFs, answer keys and previews, and the held-out set is pinned by a hash in `index.csv`.

**Why this matters.** If files drift, a baseline from Tuesday can't be compared with one from
Thursday, and "the score went up" might mean "the file got easier".

**Ask your Claude.** Give it the plan from Stage 4 and: _"Build this as a package with one module
per concern. Every random draw comes from one seeded generator per file. Every geometric effect
must also transform the answer-key boxes. Write a validator that checks every key against its
PDF, and a contact-sheet script. Then regenerate twice and prove the outputs are byte-identical."_

**Expect.** All files pass validation; a perfect attempt scores 100%; regeneration is byte-identical.

---

### Stage 6: The scorer

**What we did.** `score.py` takes an attempt (the same shape as the answer key) and reports:

- statement pages exactly right (yes/no)
- check detection precision and recall at IoU ≥ 0.5, matched greedily one-to-one per page; mean IoU
- front/back accuracy
- per-field accuracy (number, amount, payee, date) on drawn checks, with a skipped field counted
  as wrong and coverage reported separately
- matching accuracy: does each check point at the right statement line
- posting errors: read, flagged, and false alarms
- missing images: precision and recall

**The self-test.** Given a perfect attempt built from the key, it must score 100%. Given a copy
with one check removed, one amount changed and one box shifted, it must show exactly those three
misses. It did. Run this every time the scorer changes.

**Corrections, all found by real runs, not by inspection.** This is the pattern to expect:

> **Skipped fields counted as correct.** A field the attempt left blank wasn't being graded at
> all, so an attempt that answered only easy checks looked perfect. Found in baseline 1. Now:
> blank = wrong, and coverage is shown separately. _Lesson: decide what "no answer" scores before
> the first run._

> **Skipped matches counted as correct.** Same bug for matching. Found in baseline 2, where it
> hid a dense file's true 0.61 behind a 0.97. Now blank = wrong.

> **Checks-only files expected matches.** A file with no statement has no lines to match; the
> right answer is "no match". Fixed.

> **Stand-in cheques were graded on matching.** An Indian cheque carries nothing tying it to a US
> statement line; without a numbered caption, no reader could match it, and the blind run
> reasonably reported "no image for these checks". Now such images are graded on position only.
> _Lesson: when a run "fails" in a way that's obviously correct behaviour, the test is wrong._

> **`needs_review` was scored as a posting-error claim.** After the skill learned to say "I'm not
> sure of this digit", the scorer had to stop counting that as a false alarm.

> **Legibility.** Once the corpus recorded how blurred each check is (Stage 13), the scorer
> gained "amounts right out of _legible_ checks", the number that actually describes a reader.

**Ask your Claude.** _"Write a scorer for attempts in this format against these answer keys.
Report [the list above], one line per file plus totals. Include a self-test: a perfect attempt
must score 100%, and a copy with three planted errors must show exactly three misses. Decide
explicitly how an unanswered field or match is scored."_

---

### Stage 7: The blind-test protocol

**Why blind.** If the tester can see the answer key, the file name (`amount_mismatch__s2`
gives the game away), or the generator, the score means nothing. If it can wander the disk, you
can't know what it saw.

**The protocol, reused for every baseline:**

1. **Stage the files** into a scratch folder outside the project: `sandbox_A/doc_07/page_001.webp`
   … (page images only at first; later also the full PDF and the zoom script). Files are
   **renamed** to `doc_NN` with a random numbering; the mapping is kept in a folder the subagent
   is never told about.
2. **One subagent per sandbox**, 3–5 files each, run in parallel. The prompt (Appendix B)
   restricts tools to viewing images in its sandbox and writing answers to `out/`. Later runs
   allowed one shell command: the zoom script, and nothing else.
3. **Boxes as page fractions**, not pixels, so the answer carries no hint of page size and
   survives resizing.
4. **Audit the transcript afterwards.** A script reads every tool call the subagent made and
   lists any path outside its sandbox and any shell command other than the zoom tool. Every run
   in this project audited clean; two subagents reported their own slips (a shell loop around
   the allowed command) before the audit did.
5. **Convert and score** with the same scorer as everything else.

**Its limit, discovered late (Stage 15).** The prompt says "this is a blind test; guessing
invalidates it". That framing made the subagents careful in a way a normal session isn't. The
blind runs never invented data; the first real session did. **Blind tests measure capability.
They don't measure default behaviour.** Rules that must hold in production belong in the skill.

**Ask your Claude.** _"Set up a blind run: copy these files into per-agent sandboxes with
anonymized names, keep the mapping elsewhere, brief each subagent with [Appendix B], and after
they finish, audit every tool call for paths outside the sandbox before scoring."_

---

### Stage 8: Baseline 1: no skill at all

**What we did.** 14 development files, one per scenario type, 763 check fronts, page images only.
Three blind subagents. No instructions beyond the task.

**What it showed.**

|                                                                           | Result                                                           |
| ------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Statement pages identified                                                | 14/14                                                            |
| Checks found                                                              | every one, no extras, IoU 0.88–0.98; a 69-per-page grid included |
| Backs, rotated pages, joined statements, VOID, duplicates, missing images | all handled                                                      |
| Check number and amount                                                   | 100%                                                             |
| Payee and date                                                            | 0% on the dense grid; about 40% on office scans                  |

The 100% on amounts was the interesting number. The subagents said themselves that numbers and
amounts came from the _captions_ under each image, or from the statement's own list, sometimes
by position. They'd matched without reading.

**What changed.**

- **The target moved.** Finding and splitting checks was already solved. The skill's value would
  be _reading and verifying_: payee, date, and whether the written amount agrees with what the
  bank posted.
- **The corpus was too easy where it mattered.** Every file's images agreed with its statement
  and sat in statement order, often with captions. A model could pass by pairing positions. New
  scenarios were needed that only reading could pass (Stage 9).
- Two scorer bugs (Stage 6).

**Lesson.** A perfect score on the first baseline is a warning about the test, not praise for
the model. Ask _how_ it got the number.

---

### Stage 9: Removing the shortcuts, baseline 2

**What we did.** Six new scenarios, four seeds each, all with no captions and shuffled image
order, all with drawn checks so every field is gradable:

| Scenario                  | Removes or adds                                                                                                                                                      |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shuffled_no_captions`    | just the two shortcuts removed                                                                                                                                       |
| `amount_mismatch`         | 4 **posting errors**: the statement posts a different amount than the check says (transposed digits, shifted decimal, a cents slip). The check shows the true amount |
| `not_on_statement_images` | 4 images of checks the statement doesn't list                                                                                                                        |
| `no_printed_numbers`      | about a third of checks have no printed number; they must be matched by amount and date                                                                              |
| `hard_combined`           | all of the above plus missing images, repeated amounts, office-scan quality                                                                                          |
| `dense_69_hard`           | 69 per page, office scan, with mismatches and extras, about 200 checks                                                                                               |

The answer key gained `posted_amount` (what the statement shows, next to `amount`, what the
check shows), `unreadable_fields` (a check with no printed number can't be graded on it), and
the flags `amount_mismatch` and `not_on_statement`. The 178 existing files were left untouched:
every new option is off by default and draws nothing from the random stream when off, and this
was verified by regenerating and comparing.

**What it showed** (7 files, 423 fronts, page images only, blind):

|                             | Normal density               | 69 per page, office scan         |
| --------------------------- | ---------------------------- | -------------------------------- |
| Checks found                | 100%                         | 100%                             |
| Matched to the statement    | 90–100%                      | 61% (about 60 of 203 unreadable) |
| Posting errors caught       | 9 of 11, **0 false alarms**  | **0 of 4**                       |
| Unnumbered checks           | matched by amount and payee  | –                                |
| Images not on the statement | all correctly left unmatched | –                                |

Two more findings: at normal density the model **really reads** (so baseline 1's scores weren't
only shortcuts), and on the dense file it **assigned unreadable images to leftover statement
lines by elimination**, producing confidently wrong "missing image" answers. It also said, in its
notes, that its missing-image list shouldn't be trusted. It knew.

**What changed.** The wall was clear: resolution. Every serious miss was on a page where each
check had under about 200 pixels. And a rule was born: never assign by elimination when some
images are unreadable.

**Lesson.** Plant errors that only the behaviour you want can catch. A posting error is invisible
to anything that copies the statement.

---

### Stage 10: The reframing: where the skill actually runs

**What happened.** Up to here, every test used page images at the size the Legix MCP returns
(≤1568px), on the assumption the skill would fetch documents through it. The project owner
corrected this: these statements don't live in Legix. An accountant uploads the PDF **directly to
Claude**. So the skill has the **whole original file** and can bundle a script to zoom into any
region at full resolution.

**Why it matters.** The dense-page failures measured a limit the skill would never face. The
first-glance view was the _floor_, not the ceiling. (Adding crop/zoom to the MCP's document tool
was logged as a separate, later item for documents that do live in Legix.)

**A second design note, from the same conversation:** the skill would first run in the main chat
session, not in a subagent, so it couldn't rely on fanning work out. Whatever it did would have
to be fast in a single session. Options were listed for later: batch the zooming, triage before
zooming, parallel calls within one turn, subagents only where the environment offers them. All
four ended up in the skill.

**Lesson.** Ask "what will the skill have in its hands, and what won't it" before the first
measurement, not after the third. Here the first two baselines, a couple of hours of work,
measured against the wrong ceiling. It
also produced the corpus's most useful property: the first-glance previews were kept as the
"skim" view, and the full PDF became the "zoom" view, which is exactly how the skill works now.

---

### Stage 11: Zoom, baseline 3: reading solved, scaling broken

**What we did.** A script, `zoom.py`, the first draft of what the skill would bundle. It rebuilds
any page from the images embedded in the PDF at their native resolution and offers: `info`
(which pages have text, which are scans), `text` (a page's text layer), `crop` (a region, optionally
upscaled), `tiles` (a page cut into a grid). Same 7 hard files, same blind setup, plus the full
PDF and the script in each sandbox; the only shell command allowed was the script.

**What it showed.**

|                                         | Page images only | Full PDF + zoom            |
| --------------------------------------- | ---------------- | -------------------------- |
| Accuracy of checks it read (dense file) | 94–97%           | **99–100%**                |
| Posting errors on the dense file        | 0 of 4           | **3 of 4**                 |
| Normal-density files                    | good             | better; 8/8 posting errors |
| **Dense file: fronts actually read**    | 63%              | **35%**                    |
| **Time on the dense file**              | ~10 min          | **63 min, 459 tool calls** |

Zoom solved reading: every zoomed check was read almost perfectly, including 235.01 written
against 2,350.10 posted. But cropping one check at a time on a 200-check file spent an hour and
still read fewer checks than skimming had, because it ran into the **per-conversation image
limit**: images started coming back as "media removed", and the subagent had to redo work. It
reported this itself.

**What changed.** The speed note from Stage 10 became a requirement. Batch the zoom; skim first
and zoom only what's uncertain; return partial results honestly rather than run out silently.

**Lesson.** "It works" and "it works at the size the customer has" are different tests. Run the
biggest realistic case early.

---

### Stage 12: Batching and triage, baseline 4

**What we did.** Two additions to the script:

- `detect`: finds every check-sized rectangle on a scanned page (connected regions of non-white
  pixels with a check's shape and fill).
- `sheet --detect`: cuts every detected check out at native resolution and lays **12 per image**,
  each about 500px wide and labelled with its id and page-fraction box. One image replaces a
  dozen crops.

And a workflow prompt instead of free rein: read the statement; one sheet per check page; at most
about 15 targeted crops for checks still unreadable; stay under about 80 image views; make
independent calls in parallel; **record unreadable checks as `unverified` rather than guess**.

**What it showed** (the 200-check file, blind):

|                                           | Page images | Zoom, one at a time | **Batched sheets + triage** |
| ----------------------------------------- | ----------- | ------------------- | --------------------------- |
| Time                                      | ~10 min     | 63 min              | **9 min**                   |
| Tool calls                                | 18          | 459                 | **59**                      |
| Image views                               | ~15         | 427 (limit hit)     | **~51**                     |
| Fronts read                               | 63%         | 35%                 | **64%**                     |
| Right when answered (number/amount/payee) | 97/94/90%   | 100/99/97%          | **100/100/100%**            |
| Posting errors                            | 0/4         | 3/4 + 1 false alarm | **4/4, 0 false alarms**     |

The 36% it didn't read: a spot check of three at 3× full resolution showed nothing legible. They
were blurred beyond recovery _in the source_, by the generator. So 64% was this file's ceiling,
and the batched run reached it while reading nothing wrong and claiming nothing it couldn't see.

**What changed.** Two corpus issues, both fixed in Stage 13: the answer key didn't record which
checks were legible, so unreadable checks counted as misses; and the heaviest blur was harsher
than real bank scans (low resolution but usually sharp). And a new dependency: the detector
worked on clean and office scans and failed on phone photos and fax.

**Lesson.** When a run stops short, check whether the ceiling is the reader or the material
before you tune anything. Here it was the material.

---

### Stage 13: Legibility, the detector, workflow v0, baseline 5

**Legibility.** Every check in every answer key now records the blur the generator applied and
the check's pixel size. This changed no file content (verified: all 202 regenerated, keys identical
apart from the new field). A threshold was **calibrated from the previous run**: the batched run
had read 129 of 129 unblurred fronts and 0 of 74 blurred ones, and "blur under 0.4% of the
check's width" separated them exactly. The scorer gained `legible_amt`: amounts read correctly out
of legible fronts. That number is the honest description of a reader.

**The detector**, reworked for phone and fax: average the page down (fax dither becomes gray);
divide by the local paper brightness so shadows and the table around a photographed page drop
out; median-filter the speckle; close gaps at three widths and keep the most size-consistent
result (real grids repeat one size); split blobs that are exactly two checks wide (touching
front/back pairs); drop caption-height blobs. Across 12 files and 892 checks: recall 79% →
90.5%, precision 98.8%, and 3 seconds a page instead of 10 after moving the pixel work to half
resolution. Still weak on some fax and phone pages, so the workflow got a **count check**: count
the checks on the first-glance page yourself; if the detector's count differs, don't trust it for
that page, use boxes read off the page instead.

**Workflow v0.** The prompt that had worked, written as numbered steps, and used verbatim as the
brief: map the document; read the statement's checks (text layer, or 2–3 strips per page); one
sheet per check page with the count check; zoom only where needed; match by number, else by
amount and payee, never by elimination; flag posting errors, duplicates, VOID, missing images
(only when every image is accounted for); stay within budget.

**Baseline 5** (13 files: all 7 hard plus fax, phone, pairs, joined statements, real scans,
missing/duplicate/VOID; 979 images; 4 blind subagents):

|                                    | Result                                                                                          |
| ---------------------------------- | ----------------------------------------------------------------------------------------------- |
| Statement pages                    | 13/13                                                                                           |
| Checks found                       | 979/979, none extra                                                                             |
| **Legible amounts read correctly** | **482/485 (99.4%)**                                                                             |
| Posting errors                     | 14/15 read, 13/15 flagged, 1 false alarm                                                        |
| Matching                           | **100% on every file where checks are legible**                                                 |
| Detector fallback                  | failed on phone and some fax pages; the model noticed and used first-glance boxes, with no loss |
| Time                               | 7–8 min per batch of 3–4 files; 17 min for the 200-check file                                   |

Two misses, both instructive: an **unnumbered** check with a posting error couldn't be matched by
amount (the amounts differ, that's the point), so it was called "not on the statement"; and one
**false alarm** came from a cents digit misread on a blurry check.

**What changed → workflow v1:** for an unnumbered check with no amount match, look for a line
whose amount is a plausible keying error of the written amount _and_ whose posting date fits,
and only then flag it; and when a difference rests on a digit you can't read clearly, flag
`needs_review` instead of claiming a posting error.

**Lesson.** Each miss became one sentence in the workflow. That's the loop working: run, read the
misses, write the rule, run again.

---

### Stage 14: Workflow v1, the held-out test, the first package

**The held-out run.** 18 files nobody had opened, run or tuned on: seed 4 of 16 scenarios plus
two random files; 1,279 check images; 5 blind subagents; workflow v1 verbatim.

|                                | All 18                  | **15 excluding fax** | 3 fax   |
| ------------------------------ | ----------------------- | -------------------- | ------- |
| Statement pages                | 18/18                   | 15/15                | 3/3     |
| Checks found                   | 1,279/1,279, none extra | 1,162/1,162          | 117/117 |
| Legible amounts                | 598/624 (95.8%)         | **575/576 (99.8%)**  | 23/48   |
| Posting errors read / flagged  | 9/11                    | 9/11                 | –       |
| **False posting-error alarms** | **0**                   | 0                    | –       |

The v1 rules worked: the combined hard file scored 3/3 posting errors including an unnumbered
check matched through its keying error and date, the case v0 had missed.

**What fell short, and why.**

1. **Fax quality.** 150-dpi black-and-white faxes of small checks lose most of what's written.
   That's a real-world limit too; the skill needed to _say so_.
2. **The image limit is per conversation, not per document.** One subagent handled the
   204-check file plus two others in one conversation and hit "media removed"; the two missed
   posting errors were on that file. In a real session, one large statement plus a couple of
   others would do the same.
3. **Stand-in cheques.** The subagents correctly refused to match Indian cheques that carry
   nothing tying them to a statement line. One noticed that the stamp date on each check's back
   equals the statement's posting date, a pairing method for a later version.

**The first package (v0.1, then v0.2).** `SKILL.md` = workflow v1 plus a description tuned to
trigger on how accountants ask ("split out the checks", "attach check images", "reconcile checks
against a statement"); `scripts/zoom.py`, now with `page` (a first-glance render) and `export`
(**one PNG per check, plus its back, named `check_<number>_<date>_<amount>.png`, and a
`checks.csv` with a status column**: the deliverable the accountant originally asked for);
`references/output-format.md` with the results schema, the CSV columns and a report template.
v0.2 added the image budget rules (count images across the conversation; denser sheets on dense
pages; never record a value from an image that didn't load; offer one statement per conversation
for large ones) and the fax warning.

> **Correction: a commit that described changes it didn't contain.** The SKILL.md edits for
> v0.2 were applied in a shell command that began with a typo; the command aborted before the
> edit, but the package build and the commit ran anyway, with a message describing edits that
> hadn't happened. It was caught minutes later by grepping the _built package_ for a phrase the
> edit should have added, and finding zero. _Lesson: verify the artifact, not the intent. After
> any edit-and-build step, check the built output for something only the edit could have put
> there._ This is the kind of typo the guide keeps: it produced a wrong artifact that would have
> shipped.

**Ask your Claude.** _"Turn this workflow into a skill: SKILL.md with a description phrased the
way users ask, the workflow as numbered steps, a references file with the output format and a
report template, and the script under scripts/. Package it. Then unzip the package and confirm
the SKILL.md inside contains [a phrase from the latest edit]."_

**Expect.** On a held-out set: every check found, statement pages exact, ~99%+ of legible
amounts, no false posting-error claims.

---

### Stage 15: Real sessions: runs A to D, and skill v0.3 to v0.5

**Why this stage exists.** Everything so far ran in subagents under a test prompt. The skill's
user is an accountant in a normal chat. So four held-out statements were renamed neutrally
(`statement_A.pdf` … `D`), packaged with the skill and instructions, and the project owner ran
each in a **fresh Claude chat session**, asking the way an accountant would. The outputs came
back as zips and were scored against the answer keys.

| File  | What it is                                                                                          | Result                                                                                                                                            |
| ----- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A** | clean, 31 checks, 4 posting errors                                                                  | **perfect**: 31/31 on every field, 4/4 errors, 0 false alarms, VOID found                                                                         |
| **B** | phone photos, 15 checks, a duplicate, a substitute check                                            | **perfect**: 15/15 with every back paired to the right front (endorsements match payees); the detector failed and the count-check fallback worked |
| **C** | 62 checks: no captions, shuffled, unnumbered, 3 posting errors, 3 extras, 2 missing, **38 blurred** | legible checks perfect (24/24 all fields, 3/3 errors); **but 37 blurred checks were filled in**                                                   |
| **D** | **204 checks at up to 69 per page, 70 blurred**                                                     | 204/204 exported; **70/70 unverified, none filled**; legible: 134/134 status, 133/134 amount, 2/2 detectable errors; ~10 minutes in plain chat    |

**Run B found a bug in our generator.** Its chat report noted that every deposit slip in the file
itemized exactly 80% of its total. True: the slip-drawing code split each total into cash, checks
and a remainder, but never drew the cash line. Fixed with an isolated random stream so no other
file detail moved; the 32 affected files were regenerated; answer keys identical, exactly the 52
slip pages changed. _Lesson: the thing under test can audit the test data, if you read what it
says instead of only its score._ The same report also flagged same-surname payees and memo
oddities, all artifacts of random synthetic data. Whether that "extra eyes" behaviour should be
kept or capped is an open question for the owner.

**Run C is the most important result in this guide.** On the 37 blurred checks, the session did
not mark them unverified. It **filled them in**: amounts copied from the statement (which score as
"right" for a matched check, because they're equal by construction), dates and payees guessed
(17 dates wrong, 5 payees wrong, and 2 payees, such as "Braden Pierce", that appear **nowhere in
the file**). The guessed values went into the exported file names. An accountant would have
attached images labelled with invented data. And a copied amount always agrees with the
statement, so it would hide exactly the posting errors the skill exists to catch; this time every
error happened to be on a legible check.

The blind runs on the same kind of file had left those checks unverified. The difference was
framing: the blind prompt called the run a test where guessing is penalized; in a normal session,
"be complete" won. The rule had been in the workflow, once, near the end of a step. It had to
become the skill's first principle.

**Skill v0.3.** A section at the top of `SKILL.md`, "The one rule that matters most", stating the
rule _and its reason_ (a copied amount hides posting errors; a guessed payee ends up in the
books); a pre-export self-check ("for each value: did I read this on this check's image? delete
anything you didn't"); an explicit `unverified: true` field; export naming unverified checks
`check_unverified_p<page>_<n>.png` with no guessed data; and a report that leads with how many
checks couldn't be read.

**Run D, with v0.3, is the confirmation.** 70 blurred checks, 70 marked unverified, none filled
in, no invented names. The only misses were small-print misreads on ~240-pixel checks: 16 of 134
dates a day or two off (a 6 read as a 4; only 3 equalled the posting date, so they weren't
copied), 6 payees a letter off, and one date recorded honestly as `2026-06-1?`. In plain chat,
with no subagents, about ten minutes, faster than the blind runs and within the image limit.

**Skill v0.4: delegation.** A question came up: plain chat can't use subagents, but Cowork can.
So a section for environments that can delegate, used only when a statement has more than about
60 images or 4 image pages: the main session maps the document and reads the statement, splits
the check pages into ranges, starts one worker per range with a fixed brief (Appendix C), then
merges their results with a new `zoom.py merge` command and continues with matching. Two design
choices: **workers never see the statement**, so they can't copy amounts; and each worker has its
own image budget, so the per-conversation limit stops mattering. Tested mechanically (merge
drops duplicate reads, keeps unverified entries and notes); not yet run end to end in Cowork.
After run D's ten minutes, it's no longer urgent.

**Skill v0.5.** The last miss from D became a report line: on small check images, handwritten
dates and payee spellings may be a digit or letter off; check numbers and amounts were
cross-checked against the statement; uncertain digits are written as `?` rather than picked.

> **Correction: a partial download.** The first zip from run B held 3 of the 30 images the CSV
> referenced. It was scored as if complete, with a note. The full zip came later and the pairing
> was verified. _Lesson: before scoring, check that everything the output references is
> actually present._

> **Correction: a shell loop that copied one file under the wrong name.** When assembling the
> test kit, a shell variable holding four `name:file` pairs wasn't split into words by the shell in
> use, so one file was copied under a mangled name and three weren't copied at all. Caught by
> listing the folder and counting. _Lesson: after any batch file operation, list and count._

**Lesson of the stage.** Blind tests told us what the model _can_ do. Real sessions told us what
it _does_ by default. Both are needed, and the second found the failure that mattered most in a
single run.

---

### Stage 16: Output polish: designing for the first ten seconds (v0.6)

**Why this stage exists.** By v0.5 the skill was accurate, but what the accountant got was plain:
a folder of images (front and back as separate files, about 400 of them for a 204-check
statement), a CSV, and a short chat message. The valuable findings were in there (a check posted
for the wrong amount, 69 checks honestly marked unreadable), but the accountant had to go looking.
The owner asked a different question from every stage so far: not "is it right?" but _what would
make the person opening this say "oh" within a few seconds?_

**The ideas.** Claude proposed a list; the owner took all of it except one:

| Idea                                     | What it is                                                                                                                                                                   |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A review page                            | one self-contained `review.html`, opened first; works offline                                                                                                                |
| Tie-out first                            | its first line says whether the checks on the statement add up to the statement's printed "Checks Paid" total                                                                |
| Evidence cards                           | each problem shown as proof: the check beside the statement line it was posted as, with one plain sentence ("The bank posted $6,300.00 less than the check was written for") |
| An annotated copy of their own statement | the owner's pick as the strongest idea: the familiar document, marked up                                                                                                     |
| Front and back in one image              | one attachment per check is easier in QuickBooks than two                                                                                                                    |
| Status folders                           | added after the first build, at the owner's request: `checks/1_posting_errors/` … `7_matched/`, each check in exactly one, so a folder can be forwarded as is                |
| The memo line                            | the accountant's second pain on the original call was knowing _what each check was for_                                                                                      |
| Payee roll-up                            | checks grouped by payee with totals, for coding                                                                                                                              |
| "Request originals"                      | the unreadable and missing checks, plus a ready-to-send email draft: the honest "couldn't read" becomes a next step                                                          |
| A heads-up                               | "Found 204 check images; about 10 minutes", before the long part                                                                                                             |
| Bundle naming                            | `acct-7998_2026-07_checks.zip`, not a generic folder                                                                                                                         |
| ~~Pushing to QuickBooks~~                | left out: it writes to the books and needs account access; a product feature, not a skill change                                                                             |

**Four principles behind the choices.**

1. **Lead with what the user already checks.** Accountants tie things out. A green "Checks paid
   tie out: 200 checks, $559,841.54, matching the statement's printed total" earns trust for
   everything below it. (One real-session run had written a line like this unprompted; it was
   the most accountant-like sentence in any report.)
2. **Proof next to every claim.** "Posting error on #2163" is a claim. The check image showing
   $9,205.05 beside the statement row showing 2,905.05, outlined, is proof, and it's what they'd
   forward to the client or the bank.
3. **Mark up their document rather than inventing a new one.** The annotated PDF is the
   statement they already know, with a summary cover page added.
4. **Nothing new to trust.** Every value still comes from the one rule. Unreadable checks are
   grey and blank on the page, and the page says so.

**How it was built.**

- **Reading and delivering are separate scripts.** `zoom.py` reads; a new `deliver.py` turns
  the results file into everything the accountant receives. The model's job ends when it writes
  `results.json`; everything after is ordinary code. Two reasons: the model shouldn't spend its
  image budget or attention on formatting, and code can be tested without a model run. `zoom.py
export` still works, for older instructions.
- **The results file gained what the page needs:** a `statement` block (bank, last four digits
  of the account, period, the **printed** checks-paid count and total, and every check line), a
  `memo` on each check, and for flagged checks on scanned statements, the position of the
  statement line (`line`), which the model already has because it confirms the line with a
  tight crop. On statements with a text layer, `deliver.py` finds every check line itself from
  the text positions, so every line gets a box and a link.
- **Working inside the skill's sandbox.** Claude.ai's code environment has `pypdf` and `Pillow`,
  not a PDF drawing library. So the annotated PDF draws its boxes and labels directly into the
  page content (a few lines of raw PDF drawing commands), adds clickable links between statement
  lines and check images, and prepends a cover page drawn the same way with the PDF standard
  fonts. The plan had said "annotations" for the boxes; the build switched to drawing into the
  page because not every viewer shows annotations that don't carry a pre-drawn appearance, and
  pypdf doesn't draw one. The review page embeds its images, so it's one file that works offline.
- **Test support.** To test the deliverables without a model run, two things. The generator now
  records where it drew each check's statement line and puts it (and each check's memo) in the
  answer key. It draws nothing new, so all 202 files were regenerated into a scratch folder and
  compared: PDFs byte-identical, answer keys identical apart from the two new fields. Then they
  were swapped in. And a new `results_from_truth.py` writes the `results.json` a perfect run would
  produce, including the honest parts: blurred checks as `unverified` with nothing filled in,
  unprinted check numbers left out, line boxes for flagged lines only.

**How it was checked.** Six held-out statements: A to D from the real-session runs plus two
statements with a text layer.

- `deliver` took 1 to 2.5 seconds each. D's `review.html` is 3 MB and its zip 14 MB.
- All six tie out. Two forced cases showed the failure paths: a printed total $100 off, and a
  posting error on a text-layer statement. Both render as red ✗ lines.
- On the two text-layer statements, all 92 check lines were found, each on the right page.
- The review page was viewed in a browser, at desktop and phone width (no sideways scroll):
  filters, search and click-to-enlarge all work.
- Pages of the annotated PDF were rendered to images and inspected. The link targets were
  checked in the file's structure. The in-app browser can't display PDFs, so the links haven't
  been clicked in a viewer yet.
- Older results files (no `statement` block, no memo) still deliver, with a fallback bundle name.

> **Correction: the statement-line crop didn't say which line.** The first evidence card showed
> the statement row with its neighbours and nothing marking the one that mattered. Now the crop
> keeps a little context and outlines the line. _Lesson: evidence has to point, not just show._

> **Correction: a full-width line squeezed into a side column.** On statements that list checks
> inline, the line runs the width of the page; beside the check it was unreadably small. Wide
> lines now go under the check at full width.

> **Correction: a label covered the statement.** The "posting error" tag drawn next to a flagged
> statement line hid the check number in the next column. Tags moved into the page margin and
> are skipped where there's no room. _Lesson: marking up someone's document must never hide any
> of it._

**Still to do.** A real-session rerun of A and D with v0.6, judged differently from before: not
accuracy (that's known), but whether the person opening `review.html` sees the tie-out and the
proof of the posting error within seconds. And whether Claude fills in the `statement` block and
the line boxes on its own.

**Ask your Claude:**

> Write a script that takes the statement PDF and the skill's results.json and builds what the
> accountant receives, using only pypdf and Pillow: (1) a self-contained review.html: first the
> tie-out (the statement's check lines against its printed checks-paid total), then one card per
> problem showing the check image beside a crop of its statement line with the line outlined and
> one plain sentence, then a payee table, then a filterable, searchable gallery of every check with
> click-to-enlarge; (2) an annotated copy of the PDF: a summary cover page, a coloured box and
> small label on every check image, flagged statement lines boxed, statement lines linked to their
> check images, and nothing drawn over the statement's own text; (3) one PNG per check with the
> front above the back; (4) checks.csv, payees.csv and a request-originals file with an email
> draft; (5) a zip named after the account and month. On text-layer statements, find each check's
> line from the text positions. Test it on results built from answer keys, with blurred checks
> marked unverified.

**Expect:** a few seconds per statement; a review page of a few MB even for 200 checks; every
check line found on text-layer statements; a first look that turns up layout problems (ours:
three), which is why you look at the output rather than only test it.

**Lesson of the stage.** Accuracy is necessary, but the user doesn't see it; they see the output.
Design the output around the first ten seconds: what they already check (the tie-out), what
they'd have to prove to someone else (evidence), and what they'd do next (the email draft). Build
it as code downstream of the model, so it's the same every time and testable without a run.

---

### Stage 17: One workbook that stands alone (v0.7)

**Why this stage exists.** Looking at the v0.6 bundle, the owner asked for two things. First,
the check images **bucketed into folders**: by status, each check in exactly one folder,
numbered so problems sort first (`1_posting_errors/` … `7_matched/`). Second: _there are a few
outputs, CSVs and HTML; can everything go into one Excel file?_ The workbook had to **stand
alone**: readable if it's the only file forwarded, with the pictures and the annotations inside
it.

**Integrating Anthropic's xlsx skill.** Claude first said the xlsx skill's license meant it
couldn't be copied. That was too strong, and the owner pushed back: an earlier skill of theirs
(`lgx-docx`) carries Anthropic's docx toolchain verbatim with its `LICENSE.txt`, for use inside
Claude, which is what the license covers. Same approach here. The xlsx skill's `recalc.py` and
license are copied into `scripts/xlsx/`, and the workbook follows the skill's conventions:

- blue text for values read from documents, black for formulas, green for links between sheets
- real formulas rather than typed-in totals
- zero formula errors

The workbook is still built by the skill's own script, not written by the model each time.
Stage 16's reason holds: code downstream of the model is the same every run and testable.

**What's in it.**

- **Summary.** A _live_ tie-out: the printed checks-paid total next to `SUMIFS` over the
  statement lines, and a ✓/✗ formula. If an accountant corrects a line, the tie-out updates.
  Below it: posting-error totals, counts by status, links to each problem.
- **Needs attention.** The review page's evidence cards, as sheet blocks: the check picture
  beside the outlined statement line, with the written and posted amounts linked to the check's
  row.
- **Checks.** One row per check with front and back pictures in the row, anchored to "move and
  size with cells" so filtering hides them with their rows. Status colours, a note on every
  flagged status, and links to the page sheet and the image file.
- **Statement lines, Payees** (`COUNTIFS`/`SUMIFS`), **Requests** (the two email drafts, ready
  to copy).
- **One sheet per page**, marked up like the annotated PDF. That's the owner's "annotations in
  the workbook": boxes drawn onto the page pictures, plus cell notes on the rows.

Only functions every spreadsheet app has (no `FILTER` or `TEXTJOIN`). The file is set to
calculate on open. `payees.csv` and `request_originals.md` left the bundle; `checks.csv` stayed,
as a plain file for importing elsewhere.

> **Correction: the recalculation script edits the file it checks.** `recalc.py` opens the
> workbook in LibreOffice, recalculates and _saves it_. A LibreOffice re-save can drop things
> openpyxl wrote (pictures, notes). So `deliver` runs it on a copy, as a check only, and ships the
> original. It also writes a macro into the LibreOffice user profile. _Lesson: read a borrowed
> tool before you run it on something you ship._

**How it was checked (statement A, per the owner; D once for size).**

- 150 formulas, 0 errors.
- The recalculated values match the known ones: tie-out $71,729.55 ✓, posting errors
  −$9,837.94, payees $81,567.49 over 30 checks, statuses 26/4/1.
- 45 embedded pictures and no external links; the formula check ran on a copy with nothing
  else in its folder.
- Rendered through LibreOffice to page images and inspected. Two fixes came from looking: an
  empty Back column (A has no back images) is now hidden, and cramped check numbers are now
  left-aligned.
- D: 590 formulas, 0 errors, a 3.7 MB workbook in 10 seconds.
- In Excel, checked by the owner: filter and sort work on the Checks sheet, and the pictures
  move with their rows (the "move and size with cells" anchoring).

**Ask your Claude:**

> Add an Excel workbook to the deliverables, built by a script with openpyxl, that stands on its
> own: a Summary with a live tie-out (printed total vs SUMIFS of the statement lines, ✓/✗), a
> Needs-attention sheet with each problem's check picture beside its statement line, a Checks
> sheet with front and back pictures inside each row (move and size with cells) and live
> differences, Statement lines, Payees via SUMIFS, Requests with email drafts, and one marked-up
> picture sheet per page. Follow the xlsx skill's conventions; only use functions every
> spreadsheet app supports; verify zero formula errors by recalculating a copy.

**Expect:** a few MB even for 200 checks, a few hundred formulas, zero errors.

**Lesson of the stage.** "One file they can forward" is a design constraint, not a format.
It decides what must be embedded (pictures, the evidence, the email drafts) and what must be live
(the tie-out, so it still works after someone edits it).

---

## Part 4: The skill as it stands

**Name:** `check-splitter`, v0.7. **Package contents:**

- `SKILL.md` (full text in Appendix E): the description, the one rule, the tool table, the
  nine-step workflow, the don'ts.
- `scripts/zoom.py`: the reading script. Commands:

| Command                                       | Does                                                                                    |
| --------------------------------------------- | --------------------------------------------------------------------------------------- |
| `info <pdf>`                                  | which pages have a text layer and which are scans                                       |
| `text <pdf> <page>`                           | a page's text layer                                                                     |
| `page <pdf> <page>`                           | a first-glance image of a page                                                          |
| `detect <pdf> <page>`                         | boxes of the check-sized images on a page                                               |
| `sheet <pdf> <page> --detect [--per 12]`      | batched zoom: one labelled full-resolution tile per detected check, 12 per image        |
| `sheet <pdf> <page> --boxes "x0 y0 x1 y1; …"` | the same for boxes you choose (the detector fallback)                                   |
| `tiles <pdf> <page> <cols> <rows>`            | the page cut into a grid                                                                |
| `crop <pdf> <page> x0 y0 x1 y1 [--scale 3]`   | one region at full resolution                                                           |
| `export <pdf> <results.json> --out DIR`       | older: one PNG per check side + `checks.csv` (now calls `deliver.py export --separate`) |
| `merge <out.json> <part.json> …`              | combine delegated workers' results                                                      |

- `scripts/deliver.py`: the output script (Stage 16). Commands:

| Command                                  | Does                                                                                                            |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `deliver <pdf> <results.json> --out DIR` | everything below, in a folder and a zip named `acct-<last4>_<YYYY-MM>_checks`; prints the tie-out and a summary |
| `export`                                 | `checks/` (front above back; `--separate` for two files), `checks.csv`, `payees.csv`, `request_originals.md`    |
| `review`                                 | `review.html`                                                                                                   |
| `annotate`                               | `<stem>_annotated.pdf`                                                                                          |
| `workbook`                               | the standalone `.xlsx` (Stage 17)                                                                               |

- `scripts/workbook.py`: builds the workbook with openpyxl.
- `scripts/xlsx/recalc.py` + `LICENSE.txt`: copied verbatim from Anthropic's xlsx skill; run on
  a copy to confirm zero formula errors.

- `references/output-format.md`: the results JSON (including the `statement` block), what
  `deliver` writes, the CSV columns, the report template.
- `references/delegation.md`: when and how to split work across subagents; the worker brief.

**What it produces for the user:** one bundle, `acct-<last4>_<YYYY-MM>_checks.zip`:

- `acct-<last4>_<YYYY-MM>_checks.xlsx`, opened first and able to stand alone: Summary (live
  tie-out), Needs attention, Checks (pictures in every row), Statement lines, Payees, Requests,
  and one marked-up sheet per page
- `review.html`, the same review in a browser: the tie-out, then each problem with the check beside its
  statement line, then payees, then every check (filter, search, click for front and back)
- `<stem>_annotated.pdf`: their statement with a cover page, every check image boxed and
  labelled by status, flagged lines boxed, lines linked to their checks
- `checks/`: one image per check, front above back, named `check_<number>_<date>_<amount>.png`
  (or `check_unverified_p<page>_<n>.png`), each in one status folder numbered so problems sort
  first: `1_posting_errors/` … `6_couldnt_read/`, `7_matched/`
- `checks.csv` (with memo and posting date), for importing elsewhere

In chat: a heads-up with an estimate before the long part, then a short report: the tie-out, at
most three lines of what needs attention, and "open the workbook first".

**Measured performance** (Appendix D has the full ledger): on held-out synthetic statements,
every check image found, every statement identified, 99.8% of legible amounts read correctly, no
false posting-error claims. In a plain chat session, a 204-check statement in about ten minutes,
with every unreadable check reported as such.

**What it hasn't been tested on:** a real bank statement. Every file so far is synthetic. The
next honest test is one real statement from the customer, redacted if they prefer, or one of the
real multi-check PDFs already in Legix with a hand-written answer key.

---

## Part 5: Heuristics: what to carry to the next skill

These are the transferable lessons, in the order you'll need them.

**On defining the job**

1. Start from one person's specific pain, in their words. Then measure what part of it is
   actually hard; it's usually not the part they named.
2. Before the first measurement, write down what the skill will have in its hands (which files,
   which tools, which limits). Getting this wrong cost two baselines here.
3. Decide what the deliverable _is_ (here, first: a folder of named images and a CSV), and build
   the export early. It's what the user will judge. Then revisit it once the skill is accurate
   (Stage 16).

**On test material** 4. If no real data exists, generate it, and make the generator emit the answer key. Hand
labelling doesn't scale and drifts. 5. Design the answer-key schema before the generator. Include deliberate errors as first-class
fields. 6. Named scenarios for the cases you know matter; random combinations for the ones you don't. 7. Make generation byte-reproducible, and prove it by regenerating and comparing. Pin the
held-out set with hashes. 8. Hold files out from day one. Never open them until the end. 9. Record legibility (or the equivalent ceiling) in the key, and calibrate it from a run rather
than by eye. 10. Let the thing under test audit the data: read its notes, not just its score. A skill found a
generator bug this way.

**On measuring** 11. Write the scorer's self-test before the first baseline: perfect input scores 100%, three
planted errors show as exactly three. 12. Decide up front how a blank answer scores. Twice here, blanks silently counted as correct. 13. When a "failure" is obviously correct behaviour, fix the test. 14. Blind runs: anonymize file names, sandbox the agent, audit every tool call afterwards.
Cheap, and it makes the numbers mean something. 15. A perfect first baseline is a warning about the test. Ask how the score was obtained; here it
was captions and position, not reading. 16. Plant errors that only the desired behaviour can catch (posting errors that only reading
reveals).

**On finding the wall** 17. Run the largest realistic case early. Reading worked; reading 200 checks didn't. 18. When a run stops short, check whether the ceiling is the reader or the material before tuning. 19. Build the smallest tool that removes the wall, then batch it. One crop per check was right
and useless; twelve per image was right and fast. 20. Count images. The per-conversation image limit is real, silent, and per conversation, not per
document.

**On the skill itself** 21. The prompt that worked in the runs is the first draft of the skill. Each miss becomes one
sentence. 22. Blind tests measure capability under a careful framing. Real sessions measure default
behaviour. Run both; the second finds different failures. 23. Rules that must hold in production go at the top of the skill, with the reason. A rule
buried at the end of a step was ignored the first time completeness competed with it. 24. Prefer "unverified" to a guess, and make the tooling enforce it (unverified checks export
with no guessed data in their names). 25. Give the model a way to say "I'm not sure of this digit" (`needs_review`, `?`) so it
doesn't have to choose between a false claim and a guess. 26. Write the report template. Lead with what needs attention; never imply everything was
verified if some wasn't. 27. Have the workflow check its own tools (the detector count check). Tools fail on inputs you
didn't build them for; the model can notice if you tell it to look.

**On the deliverable** 28. Lead with the check the user already does. For an accountant, that's the tie-out. 29. Put proof next to every claim: the image and the line it disagrees with, side by side. 30. Mark up the user's own document rather than inventing a new one, and never cover any of it. 31. Turn dead ends into next steps: "couldn't read 38 checks" comes with the email asking for them. 32. Keep the model's output a data file, and make everything after it deterministic code. It's
the same every run, costs no image budget, and can be tested without a model.

**On process** 33. Verify the artifact, not the intent: after edit-and-build, look inside the built package. 34. After batch file operations, list and count. Before scoring an output, check that everything
it references is present. 35. Keep a dated log: what, why, result, next. Write it the same day. This guide exists because
that log did. 36. Version the skill on every behaviour change, and record which run produced the change.

---

## Part 6: Open items

- **A real statement.** The customer offered one client as a test. Ask for one month's
  statement, redacted, and run it. Also: hand-write answer keys for the real multi-check PDFs
  already in Legix.
- **The delegation path (v0.4) end to end** in Cowork, timed against the ten-minute plain-chat
  run on statement D.
- **The chat reply.** None of the manual runs captured the report's wording. Whether the report
  should include "extra eyes" observations beyond checks (run B did) is undecided.
- **Fax legibility.** The corpus's legibility flag models blur, not fax degradation, so it
  overstates what's readable on fax pages.
- **Stand-in images.** Pair Indian cheques to statement lines by the posting date stamped on
  each back, as one subagent noticed.
- **The Legix MCP.** Add crop/zoom to its document tool for documents that do live in Legix.
  Separate from this skill.
- **Blur realism.** The generator's heaviest blur is harsher than real scans; consider capping it.
- **The v0.6 rerun.** Run A and D again in a fresh chat with v0.6. Judge the first ten seconds of
  `review.html`, and check that Claude writes the `statement` block and the flagged-line boxes
  itself.
- **Click the PDF links** in Preview and Acrobat. They were checked in the file's structure only.
- **Pushing to QuickBooks** (the check images as attachments on the matched transactions), through
  Legix. Deliberately left out of v0.6.

---

## Appendix A: The answer-key schema

One `<id>.truth.json` per file. The fields the scorer uses are marked ★.

```
id, scenario, seed, split (dev|holdout), generator_version, file
axes            the sampled configuration (every axis value)
license_sources ["drawn", "hf", "idrbt", "impact_bank_sample"] as applicable
pages[]         page, kind (bank_statement|check_images|substitute_check|notice|blank),
                statement_index, raster, quality, rotation_turns, size_pt, preview
segments[] ★    consecutive pages of one kind: type, statement_index, pages[],
                contains_check_images (a statement page that also holds checks)
statements[] ★  index, bank, template, account, holder, period_start, period_end,
                opening_balance, closing_balance, pages[],
                transactions[]: id, date, desc, amount (signed), balance, kind,
                                and for checks: check_number, payee, memo,
                                line {page, bbox_pt}: where the check's statement line is drawn
checks[] ★      one per check image:
                page, bbox_pt [x0,y0,x1,y1] (PDF points, origin top-left, as displayed),
                rotation_deg, side (front|back), pair_id (links a front to its back),
                check_number, amount (AS WRITTEN ON THE CHECK), posted_amount (what the
                statement shows, only when it differs), payee, memo, date (written), cleared_date,
                transaction_id (null for VOID / not on statement),
                source (drawn|hf|idrbt), source_image, field_ground_truth (true only for drawn),
                flags [] of amount_mismatch|not_on_statement|void|duplicate|substitute,
                unreadable_fields [] e.g. ["check_number"] when none is printed,
                render {blur_px, px:[w,h]} for legibility
other_items[]   deposit slips: page, bbox_pt, transaction_id, amount, date
missing_images[] ★ transaction_id, check_number, reason (image_not_included|no_images_in_file)
counts          pages, check_fronts, check_backs, other_items, missing_images
```

Design notes: `amount` is always what the check says and `posted_amount` what the bank posted,
so a posting error is simply "both present and different". `field_ground_truth: false` marks
images whose text can't be graded. `render` is what legibility is computed from (legible if
`blur_px / px[0] < 0.004`).

## Appendix B: The blind-test prompt

The core of the prompt used for every blind run (tool list and paths varied by run; this is the
final form, with the zoom tool):

```
You are doing a document-reading task. Work ONLY inside this folder (call it SANDBOX): <path>
Documents: doc_NN, doc_MM. Each folder holds doc_NN.pdf (the full PDF an accountant uploaded)
and page_001.webp, page_002.webp, … (first-glance page images).
Run the zoom tool as: cd SANDBOX && ./zoom <command> doc_NN/doc_NN.pdf ...

STRICT RULES (blind test; breaking them invalidates it):
- Tools allowed: Read (only files inside SANDBOX), Write (only into SANDBOX/out/), and Bash ONLY
  for `cd SANDBOX && ./zoom ...` (single commands; no loops, ls, cat, python, or anything else).
- Do not read, list, or search anything outside SANDBOX. There is nothing else to find.

=== WORKFLOW ===
<the workflow steps, verbatim from the version under test>
=== END WORKFLOW ===

Write SANDBOX/out/doc_NN.json per document, exactly this shape:
{ "statement_pages": [1, 2],
  "checks": [ {"page": 4, "box": [x0,y0,x1,y1], "side": "front", "check_number": 1234,
               "amount": 50.00, "payee": "Acme Co", "date": "2026-10-05",
               "matches": {"check_number": 1234, "amount": 50.00}, "flags": []},
              {"page": 4, "box": [...], "side": "back"} ],
  "missing_images": [1240], "unverified": [], "notes": "..." }
Boxes are fractions (0–1) of the page, origin top-left. matches: the statement line's values;
{} if on no line; leave it out if you couldn't read the check.

When finished, reply per document: pages, fronts/backs found, fronts fully read, sheets viewed,
targeted crops, mismatches found, detector problems, what was hardest. Nothing else.
```

The audit afterwards: read every tool call in the transcript; list any `file_path`/`path`/`pattern`/`url`
not under SANDBOX; for shell commands, list any absolute path outside SANDBOX and any command word
other than `cd` and `./zoom`. Zero findings, or explain each.

## Appendix C: The delegation worker brief

Given to each subagent when the skill delegates (v0.4). Workers never receive the statement.

```
You are reading check images from part of a bank statement PDF.
File: <path>   Your pages: <e.g. 7–9> (only these)   Zoom tool: python3 <skill>/scripts/zoom.py

For each of your pages:
1. See the page and count the check images yourself.
2. Run `zoom.py detect`. If its count differs from yours, or boxes look merged or off the checks,
   use `sheet --boxes "..."` with boxes you read off the page; otherwise `sheet --detect`.
3. For every tile: front or back. For fronts, read check_number, amount (figure box, checked
   against the words), payee and date, all as written on the check.
4. A front you can't read on its sheet: one `crop --scale 3`. Still unreadable →
   "unverified": true, keep only what you truly read.
5. If a back clearly belongs to a front, put it on that front as "back": {page, box}.

THE ONE RULE: every value must be read off that check's image. Never guess, never infer from
the number sequence, never fill a field because it "should" be there.

Budget: at most about 60 image views. Make independent zoom calls together in one turn.
Write part_<first>-<last>.json: {"checks": [...], "unverified": [], "notes": "..."}
Don't add "matches"; the coordinator does that.
Reply with one line: pages done, fronts, backs, unverified count, anything the coordinator must know.
```

## Appendix D: Results ledger

**Blind baselines** (subagents, anonymized files, audited; all audits clean)

| #   | Date  | Setup                                 | Files / images    | Key results                                                                                                           |
| --- | ----- | ------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------- |
| 1   | 09-23 | no skill, page images only            | 14 / 763 fronts   | stmt 14/14; detect 100%; number & amount 100% (via captions/statement); payee 0.59, date 0.47; dense: payee/date 0.00 |
| 2   | 09-24 | no skill, hard scenarios, page images | 7 / 423 fronts    | detect 100%; number 0.79, amount 0.78, match 0.80; posting errors 9/15 read, 0 false; dense: 0/4, match 0.61          |
| 3   | 09-24 | full PDF + zoom, one crop at a time   | 7                 | zoomed reads 99–100%; dense 3/4 errors (+1 false), 63 min, 459 calls, image limit hit, 35% coverage                   |
| 4   | 09-24 | batched sheets + triage               | dense file only   | 9 min, 59 calls, ~51 views; 4/4 errors, 0 false; 100% right when answered; 64% coverage = legible ceiling             |
| 5   | 09-25 | workflow v0                           | 13 / 979 images   | stmt 13/13; detect 979/979; legible amounts 482/485 (99.4%); errors 14/15 read, 1 false; match 100% where legible     |
| 6   | 09-25 | workflow v1, **held-out**             | 18 / 1,279 images | stmt 18/18; detect 1,279/1,279; legible amounts 575/576 (99.8%) excl. fax, 23/48 fax; errors 9/11, 0 false            |

**Real-session runs** (fresh Claude chat, packaged skill, held-out files renamed)

| File                         | Skill | Result                                                                        | Led to                    |
| ---------------------------- | ----- | ----------------------------------------------------------------------------- | ------------------------- |
| A: clean, 31 checks          | v0.2  | perfect; 4/4 posting errors                                                   | –                         |
| B: phone photos, 15          | v0.2  | perfect, backs paired; found a generator bug                                  | deposit-slip fix          |
| C: hard mix, 62 (38 blurred) | v0.2  | legible perfect, 3/3 errors; **37 blurred filled in, 2 payees invented**      | v0.3 (the one rule)       |
| D: 204 checks, 70 blurred    | v0.3  | 70/70 unverified; legible 134/134 status, 133/134 amount, 2/2 errors; ~10 min | v0.5 (small-print caveat) |

**Deliverables (v0.6)**, from perfect results files built from the answer keys: 6 held-out
statements (A–D + 2 text-layer), 1–2.5 s each; all tie out; the ✗ paths render on forced cases;
92/92 check lines found on the text-layer statements; D: `review.html` 3 MB, zip 14 MB.

**Detector:** recall 79% → 90.5%, precision 98.8%, 3 s/page (12 files, 892 checks). 100% on clean, office and flattened scans; partial on fax and phone; the count check covers the rest.

## Appendix E: SKILL.md, full text

```markdown
---
name: check-splitter
description: Read a bank statement PDF whose pages include check images (a few per page up to ~70 per page), split every check out into its own image file, read each check (number, amount, payee, date), match it to the statement line, and flag posting errors, missing images, checks not on the statement, duplicates and VOID checks. Delivers an Excel workbook that stands on its own (live tie-out, each problem with the check beside its statement line, every check with its picture, payees, email drafts, marked-up pages), a one-page review, an annotated copy of the statement, and one image per check sorted by status. Use when an accountant or bookkeeper uploads a bank statement with check images and asks to split the checks, pull out check images, match checks to the statement, attach check images to transactions, find what each check was for, or reconcile checks against a statement. Also use for a PDF of check images without a statement.
---

# Check Splitter

Turns a bank statement with check images into one bundle (`acct-<last4>_<YYYY-MM>_checks.zip`):

- **`acct-<last4>_<YYYY-MM>_checks.xlsx`**, the workbook, which stands on its own if it's the
  only file sent: Summary (a live tie-out against the statement's printed total), Needs
  attention (each problem with the check image beside the statement line), Checks (every check
  with front and back pictures in the row, filterable), Statement lines, Payees, Requests (email
  drafts), and one marked-up sheet per page
- **`review.html`**, the same review as a web page: does the statement tie out, what needs attention
  (each posting error shown as the check beside the statement line it was posted as), payees,
  and every check, searchable. One file, works offline.
- **`<statement>_annotated.pdf`**: their own statement, with a summary cover page, every check
  image boxed and labelled by status, and flagged statement lines boxed and linked to the check
- **`checks/`**: one image per check, front above back, ready to attach in QuickBooks, sorted
  into one folder per status (`1_posting_errors/` … `6_couldnt_read/`, `7_matched/`)
- **`checks.csv`** (number, date, payee, memo, amounts, status), for importing elsewhere
- a **short report** in chat of what needs attention

Built and tested blind against a 202-file synthetic corpus: dense 69-per-page grids, faxes,
phone photos, joined statements, posting errors and more. On **held-out** files it had never
seen, it found every check image, identified every statement, read **99.8% of legible amounts**
correctly, and made **no false posting-error claims**. It catches posting errors that only show up
by reading the check itself.

## The one rule that matters most

**Every value you record for a check must be read off that check's image.** Never fill a
check's amount, payee, date or memo from the statement, from the check-number sequence, or from
what "should" be there. If you can't read the amount on the check itself, even at full zoom,
and even when its number is readable, that check is **unverified**.

- Record `"unverified": true` and leave the unreadable fields out.
- It's exported as `check_unverified_p<page>_<n>.png`.
- The report tells the accountant to pull the original.

Why: a copied amount always "agrees" with the statement, so it hides exactly the posting errors
this skill exists to catch. A guessed payee or date ends up in the file names and CSV that
the accountant attaches to their books. An honest "couldn't read 38 of 62" is useful; a
confident, filled-in, partly invented table is harmful. (Tested: a run that filled in blurred
checks invented payees like "Braden Pierce" that appear nowhere in the file.)

## Requirements

- Python 3 with `pypdf`, `Pillow` and `openpyxl`. If any is missing: `pip install pypdf pillow openpyxl`.
  Without openpyxl everything but the workbook is still delivered.
- The uploaded PDF on disk. Reading uses `scripts/zoom.py` from this skill:
  `python3 <skill-dir>/scripts/zoom.py <command> <pdf> ...`; the deliverables come from
  `scripts/deliver.py` (step 8).

## What "zoom" gives you

The page images you see of an uploaded PDF are a first-glance view, often too small to
read checks on dense pages. `zoom.py` reads the PDF's embedded scans at full resolution.
Coordinates are fractions (0–1) of the page, origin top-left.

| Command                                         | Use                                                                              |
| ----------------------------------------------- | -------------------------------------------------------------------------------- |
| `info <pdf>`                                    | which pages have a text layer and which are scans                                |
| `text <pdf> <page>`                             | a page's text layer (digital statements)                                         |
| `page <pdf> <page>`                             | a first-glance image of a page, if you can't already see it                      |
| `detect <pdf> <page>`                           | boxes of the check images on a page                                              |
| `sheet <pdf> <page> --detect`                   | **batched zoom**: 12 labelled checks per image, each readable, each with its box |
| `sheet <pdf> <page> --boxes "x0 y0 x1 y1; ..."` | the same for boxes you choose                                                    |
| `tiles <pdf> <page> <cols> <rows> [--scale S]`  | the page cut into a grid                                                         |
| `crop <pdf> <page> x0 y0 x1 y1 [--scale 3]`     | one region at full resolution                                                    |
| `merge <out.json> <part.json> ...`              | combines page-range results from delegated workers                               |

Outputs go to `./zoom_out/` unless `--out` says otherwise. View the PNGs to read them.
When the reading is done, `deliver.py deliver <pdf> <results.json> --out DIR` builds everything
the accountant gets (step 8).

## Workflow

1. **Map the document.** Run `info`. Look at each page once (first-glance) and note:
   statement pages, check-image pages, and anything else (deposit slips, notices, blank pages).
   Run `detect` on the check-image pages for a rough count.
   **Then tell the user what you found and how long it will take**, in one line, before the long
   part: e.g. "Found about 200 check images on 6 pages after a 6-page statement. Reading them in
   batches; this takes about 10 minutes." Rule of thumb: about a minute per 20 checks, plus a
   couple of minutes. Nobody should wait ten minutes wondering whether anything is happening.
2. **Read the statement's checks.** Use `text` on pages with a text layer. Otherwise `crop` each
   statement page in 2–3 horizontal strips at `--scale 1.5`. Tilted scans can shift the
   amount column a row: pair by column order or running balance, and confirm the totals.
   Write down, for the results file's `statement` block: bank name, the account's last 4 digits,
   the period, the **printed** checks-paid count and total (from the summary), and **every
   check line** (number, posting date, amount). The review page ties the lines out against the
   printed total, so copy the printed total as printed, not your sum.
3. **Batch-read the check images.**
   - **If you can start sub-agents** (Cowork, Claude Code) and the statement has more than about
     60 check images or more than 4 check-image pages, **delegate this step**: follow
     [references/delegation.md](references/delegation.md). Workers read page ranges in parallel,
     each with its own image budget, and you merge their results and carry on from step 5.
   - **Otherwise** (plain chat, or a small statement), do it yourself: for each check-image page,
     run `sheet --detect` and read the sheets.
   - **Check the detector on every page.** Count the checks on the first-glance page and compare
     with `detect`'s count. If they differ, or the boxes look merged or off the checks (common
     on phone photos and fax), use `sheet --boxes` with boxes read off the page, or `tiles` in
     strips, for that page.
   - For each tile, record front or back. For fronts, record number, amount (as written: the
     figure box, checked against the words), payee, date and memo (blank if there is none).
     The tile's box is the check's box.
   - Where a back clearly belongs to a front (next to it or right under it), put it on that
     front as `"back": {"page": …, "box": […]}`.
4. **Zoom only where needed.** One `crop --scale 3` for a front you can't read on its sheet.
   At most about 15 targeted crops per document. Some scans are blurred beyond recovery, and
   on some statements that's a third of the checks or more. If it's still unreadable, mark it
   `"unverified": true`, keep only what you truly read (often just the box, maybe the number),
   and move on. Don't fill in the rest; see "The one rule" above.
5. **Match each front to its statement line.**
   - **By check number** when it's printed.
   - **No printed number:** by amount. If no line has that amount, look for a line whose amount
     is a plausible keying error of the written amount (transposed digits, shifted decimal,
     cents slip) and whose posting date is on or a few days after the date on the check.
     Match it and flag the posting error.
   - Only call a check **not on the statement** when nothing fits by number, amount, or a
     plausible keying error with a fitting date.
   - **Never match by elimination** when some images are unreadable.
6. **Flag.**
   - **Posting error** (`amount_mismatch`): the figure box and the words agree on the written
     amount, and it differs from what the statement posted.
   - **`needs_review`:** a difference that rests on a digit you can't read clearly.
   - **`duplicate`:** the same check imaged twice. **`void`:** stamped VOID.
   - **Missing images:** checks the statement lists with no image. Only report these when every
     image is accounted for; otherwise say they can't be determined.
   - **Box the statement line of every flagged check** (posting error or needs review) when the
     statement page is a scan: confirm the line with one tight `crop` around it, and record that
     crop's box as the line's `line` in the `statement` block. The review page shows it beside the
     check. Text-layer statements don't need this; `deliver.py` finds their lines itself.
   - For a `needs_review` check, add a one-sentence `note` saying what's uncertain.
7. **Budget and speed.**
   - **Images are limited per conversation, not per document.** Past roughly 80–100 image
     views, earlier images get dropped ("media removed"), and you can no longer see what you're
     reading.
   - Keep a running count. Read statements through `text` where possible. On dense pages
     (40+ checks), use `sheet --detect --per 20`.
   - For a statement with more than about 150 checks, or several statements in one
     conversation, tell the user up front. If you can delegate, do (see step 3). If not, offer
     to do one statement per conversation, or page ranges in turns.
   - If an image comes back removed, **don't record values from it**. Re-read it, or list it as
     unverified.
   - Make independent tool calls together in one turn (e.g. all of a document's `sheet`
     commands at once).
   - **Fax-quality pages** (grainy black and white, dithered) often can't be read on small
     checks at any zoom. Say so in the report and list the checks that need the originals.
8. **Self-check, then write the results.** Before writing, go through every front and ask
   of each amount, payee, date and memo: _did I read this on this check's image?_ Delete anything
   that came from the statement, the number sequence, or inference, and mark the check
   `"unverified": true` if its amount goes.
   Then write the results as JSON (format in [references/output-format.md](references/output-format.md))
   and build the deliverables:
   `python3 <skill-dir>/scripts/deliver.py deliver <pdf> results.json --out <output folder>`
   Put the output where the user can download it (e.g. `/mnt/user-data/outputs/` on claude.ai,
   or next to the PDF). It prints the tie-out, the workbook's formula check (it recalculates a
   copy with the xlsx skill's `recalc.py` when LibreOffice is available; zero errors expected),
   and a summary to build the report from. Don't edit the workbook afterwards; rerun `deliver`. If it says
   the check lines don't tie to the printed total, re-read the statement's check list before
   reporting.
9. **Report to the user.** Short; the workbook and review page carry the detail. See the template in the
   reference: the tie-out line, **at most three lines** of what needs attention (posting errors,
   checks not on the statement, missing images, **how many checks couldn't be read**), then
   "Open the workbook (`….xlsx`) first; `review.html` shows the same in a browser" and the bundle. Never imply every check was verified if some
   weren't.
   - **Small check images.** If checks are small in the scan (dense grids, roughly 40+ per page,
     or under about 300 px wide), add one line: handwritten dates and payee spellings on them
     may be a digit or letter off. Check numbers and amounts were read against each other and
     against the statement, so the matches themselves hold. (Tested on a 69-per-page statement:
     every amount and status was right, but about 1 in 8 dates was a day or two off, e.g. a 6
     read as a 4.) Where you weren't sure of a digit, write it as `?` (`2026-06-1?`) rather
     than picking one.

## Don'ts

- Don't copy amounts (or payees, or dates) from the statement onto checks, not even for
  checks whose number you can read. Read each check. The point is to catch where they differ.
- Don't guess unreadable fields or assign checks by elimination. "Unverified" is a correct
  answer; a confident wrong match isn't.
- Don't treat foreign or sample cheques that carry no US check number as matches unless the
  bank's caption under each image ties them to a statement line.
```

_The `zoom.py` script (about 380 lines: PDF page rebuild from embedded images, the detector,
sheet layout and merge) is described in Stages 11–14 and Part 4, and `deliver.py` (about 1,100
lines: tie-out, review page, annotated PDF, exports) in Stage 16 and `workbook.py` (about 700
lines) in Stage 17, in enough detail to rebuild.
Ask your Claude for them with those descriptions and the command tables above._
