◉CLAUDE LABJP
●CODE 2.1.322 — Fast mode now works on Vertex in the global, us and eu regions (11/10), falling back to standard speed if refused●FABLE 5.2 — Claude Code 2.1.321 makes Fable 5.2 the default Fable model: 1M context at $8/$40 per Mtok●MANAGED — Self-hosted Claude Managed Agents is out (11/12): you run the whole service yourself●SONNET 4.5 — Retires on the Claude API on 11/30, 17 days left. Migrate to Sonnet 5.5●MCP APPS — Users report local MCP app views no longer showing in Chat after a desktop update●NEW — Cowork or Claude Code First, With No Terminal Experience●CODE 2.1.322 — Fast mode now works on Vertex in the global, us and eu regions (11/10), falling back to standard speed if refused●FABLE 5.2 — Claude Code 2.1.321 makes Fable 5.2 the default Fable model: 1M context at $8/$40 per Mtok●MANAGED — Self-hosted Claude Managed Agents is out (11/12): you run the whole service yourself●SONNET 4.5 — Retires on the Claude API on 11/30, 17 days left. Migrate to Sonnet 5.5●MCP APPS — Users report local MCP app views no longer showing in Chat after a desktop update●NEW — Cowork or Claude Code First, With No Terminal Experience
Articles/Claude.ai
◉ Claude.ai/2026-11-12Intermediate

A Short Script to Check That the Spreadsheet Cowork Built Matches Your Source Data

After handing invoice transcription to Cowork, compare the workbook against the source CSV and catch missing rows, swapped digits, and amounts stored as text. Reproduced with dummy data.

Cowork64Excelverification7Python31data entryworkflow43

The night I wrote this check, I started by sabotaging my own workbook. I took ten dummy invoices and planted three mistakes: one row removed, one amount with two digits swapped, and one amount stored as text. Then I tried to spot the problem by eye from the total cell. I couldn't. The workbook said 276,470; the source said 291,650. A gap of 15,180 doesn't point at anything.

Here is the idea I'd like to leave first: whoever did the transcription, keep the check on a separate path. I'm not claiming Cowork produces these three errors. They are simply the shapes that show up whether a person, a macro, or an AI does the copying, so it's worth listing them before they cost you a month-end.

Three ways a transcription drifts

I used ten made-up invoices across three vendors. No real data is involved. The source is a CSV, the transcribed result is an Excel workbook, and I planted these:

DriftWhat I plantedWhy it's hard to see
Missing rowRemoved INV-1005 (5,400)Ten rows become nine; the list still looks natural
Swapped digitsINV-1007 changed from 22,750 to 22,570Same length, same look
Amount as textINV-1003 entered as the string "9,600"Looks like a number on screen, but SUM skips it

The third one was the nastiest. The total cell held =SUM(D2:D10), and a text "9,600" isn't added, so Excel shows 9,600 less than even the plain sum of what's actually there (286,070). The errors stack, and you can no longer pin the gap on a single cause.

Match by key, not by total

The first instinct is to compare totals. But a mismatch tells you nothing about which row is responsible, and some errors cancel out (+100 in one row, -100 in another).

So I join on the invoice number and check four things:

  • Rows in the source but not in the workbook (missing)
  • Rows in the workbook but not in the source (extra)
  • Same invoice number, different amount (mismatch)
  • Amount stored as text instead of a number (type)

Counts and totals are printed at the end as context only. If the rows match, the total matches. The reverse doesn't hold. I try to keep that direction straight.

The script

It expects a source CSV with invoice_no,date,vendor,amount and a workbook with the same four columns. Rows that aren't invoices, such as the total row, are skipped by checking for the INV- prefix.

"""Verify a transcribed workbook against its source CSV."""
import csv, sys
from decimal import Decimal, InvalidOperation
import openpyxl
 
def to_amount(v):
    """Normalize numbers and strings like "9,600" or "¥9,600" to Decimal; None if unreadable."""
    if isinstance(v, (int, float)):
        return Decimal(str(v))
    try:
        return Decimal(str(v).replace(",", "").replace("¥", "").replace("¥", "").strip())
    except (InvalidOperation, AttributeError):
        return None
 
def load_source(path):
    with open(path, encoding="utf-8") as f:
        return {r["invoice_no"]: Decimal(r["amount"]) for r in csv.DictReader(f)}
 
def load_sheet(path):
    ws = openpyxl.load_workbook(path).active      # formula cells come back as formula strings
    rows, raw_types = {}, {}
    for r in ws.iter_rows(min_row=2, values_only=True):
        no, _date, _vendor, amt = r[:4]
        if not no or not str(no).startswith("INV-"):   # skip the total row etc.
            continue
        rows[no] = to_amount(amt)
        raw_types[no] = type(amt).__name__
    return rows, raw_types
 
def main(src, xlsx):
    s = load_source(src)
    t, types = load_sheet(xlsx)
    problems = []
    for no in sorted(s.keys() - t.keys()):
        problems.append(f"missing  {no}: source has {s[no]:,} but the workbook has no row")
    for no in sorted(t.keys() - s.keys()):
        problems.append(f"extra    {no}: not in the source")
    for no in sorted(s.keys() & t.keys()):
        if t[no] is None:
            problems.append(f"invalid  {no}: amount cannot be read as a number")
        elif t[no] != s[no]:
            problems.append(f"mismatch {no}: source {s[no]:,} / workbook {t[no]:,}")
        if types.get(no) == "str":
            problems.append(f"type     {no}: amount is stored as text (SUM will skip it)")
    print(f"source rows {len(s)} / workbook rows {len(t)}")
    print(f"source total {sum(s.values()):,} / workbook total {sum(v for v in t.values() if v is not None):,}")
    for p in problems:
        print(" -", p)
    return 1 if problems else 0
 
if __name__ == "__main__":
    sys.exit(main(sys.argv[1], sys.argv[2]))

Run it with python3 verify_transcription.py source_invoices.csv transcribed.xlsx. On my three planted errors, it printed this (the labels are in Japanese in my original run; I translated them here):

source rows 10 / workbook rows 9
source total 291,650 / workbook total 286,070
 - missing  INV-1005: source has 5,400 but the workbook has no row
 - type     INV-1003: amount is stored as text (SUM will skip it)
 - mismatch INV-1007: source 22,750 / workbook 22,570

The exit code is 1 when anything is found and 0 otherwise, which matters in the handoff below.

Why Decimal, and why convert text first

Comparing floats breaks on invoices with fractional amounts, such as tax rounding, where values that look identical differ by a binary rounding error. Normalizing through Decimal(str(v)) avoids that kind of false alarm.

I also convert the text "9,600" to a number before comparing, because I wanted to tell apart "the value is right but the container is wrong" from "the value is wrong". Different causes, different fixes: a type problem is solved by resetting the column format to numbers; a mismatch means going back to the source.

There's a limit worth stating. The script doesn't compare dates or vendor names. The same structure extends to them easily, but I started with amounts only because a check that reports everything on day one tends to produce output nobody reads.

How I hand it to Cowork, and where I draw the line

When I ask Cowork to transcribe something, I leave this script in the same folder as the source and end my request with one sentence:

When you finish, run verify_transcription.py in the same folder and report its output as is. Fix any flagged rows only after reporting.

The key part is seeing the output before anything is fixed. If the fix happens first, I never learn what actually went wrong. With the output in hand, I can tell a missing row from a swapped digit and adjust my next request (for example, "keep the amount column numeric").

Still, whoever ran the script is the same party that did the transcription. For months that matter, I run the same command myself in my own terminal. The one who copies and the one who checks should be different hands as far as possible. As an indie developer, I hold to that when I hand invoice entry to a contractor, too.

One small next step

Pick one already-transcribed workbook and find its source CSV. Then change just two things in the script, the INV- prefix and the column order, to match your own data. If the first run prints nothing, you've learned the sheet really is right. If it prints something, you've got your next prompt to rewrite.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◉ Claude.ai2026-10-15
Let Claude Draft the Quote's Wording, Keep the Numbers in Your Own File — A Small Source of Truth and a Check Script
How a freelancer can have Claude polish the wording of quotes and invoices while the amounts stay in a JSON file and get verified by a short Python script.
◉ Claude.ai2026-11-01
"Not Sooner Than November 24" Is Not a Retirement Date: Matching My Own Model IDs to the Table on a Sunday
How to read Active, Deprecated and Retired in Claude's model retirement table, and why a 'not sooner than' date is only a floor. Includes a 60-line Python script that counts the model IDs in your own files, with the output from my run.
◉ Claude.ai2026-10-26
When Cowork Stalls on Its First Task in Windows, Five Things I'd Check Before Touching Any Setting
A checking order for Windows machines where Cowork won't start or stops at the first task: installer type, S mode, the virtual machine feature, free space, and security software, rebuilt from the official Windows checklist.