The night before I send a quote for a client's website, the items are settled, the prices are settled, and all that remains is the short note that goes with the document.
Claude is very good at that note. It returns something polite but not stiff in a few seconds.
But when I put the amounts into the request as well, I noticed that numbers could quietly move around in the reply. Not by a digit — the figure sitting next to "total" would turn out to be the subtotal, or the pre-tax amount.
A sentence I can fix by rereading it. A number I can't, not reliably. That became the starting point for where I draw the line.
Don't hand over the numbers at all
I'm not doubting Claude's arithmetic. Addition and sales tax usually come back correct.
The trouble comes earlier: once amounts are scattered through a piece of prose, I stop knowing which one is the source of truth. So I split the roles.
| Role | Owner | Where it lives |
|---|---|---|
| Items, unit prices, quantities, tax rate | Me | A JSON file I keep |
| Subtotal, tax, total | A script | The Python below |
| Cover note, subject line, closing words | Claude | The chat draft |
| Final cross-check | Script, then me | Right before sending |
The quote PDF itself is produced from the same kind of JSON by a separate step. This article covers only the part where Claude writes the words.
The source of truth is one small JSON file
Nothing elaborate: item names, unit prices and quantities.
{
"items": [
{"name": "Top page", "unit_price": 120000, "qty": 1},
{"name": "Inner pages", "unit_price": 30000, "qty": 3}
]
}Save it as quote.json. When an amount changes, I change this file and nothing else. I never go back to the chat to say "rewrite it with this new amount."
Ask Claude with holes where the numbers go
I give Claude the request with the amounts masked:
Write a cover note for a quote to send to a client, in polite plain English, 4 sentences max.
- Subject: Quote for corporate website production
- Two items (top page / 3 inner pages)
- Do not write any amounts; put "[TOTAL]" where the total goes
- Put "[VALID UNTIL]" where the expiry date goes
- End with a short line inviting questionsThe draft comes back with "[TOTAL]" and "[VALID UNTIL]" intact. The only number that goes into the first hole is the one the script prints.
The check script: catch numbers that slipped in
After I fill in the draft, I run a script that does three things: it computes subtotal, tax and total from the JSON; it collects every number of four or more digits in the draft; and it stops if any number can't be derived from the JSON.
import json, re, sys
from decimal import Decimal, ROUND_DOWN
def load(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def calc(items, rate=Decimal("0.10")):
subtotal = sum(Decimal(i["unit_price"]) * Decimal(i["qty"]) for i in items)
# Round tax once per rate, not per line (rounding down here)
tax = (subtotal * rate).quantize(Decimal("1"), rounding=ROUND_DOWN)
return subtotal, tax, subtotal + tax
def amounts_in(text):
found = set()
for m in re.finditer(r"[0-9][0-9,]*", text):
s = m.group().replace(",", "")
if len(s) >= 4: # ignore fragments of dates and phone numbers
found.add(int(s))
return found
def main(json_path, draft_path):
doc = load(json_path)
subtotal, tax, total = calc(doc["items"])
allowed = {int(subtotal), int(tax), int(total)}
allowed |= {int(Decimal(i["unit_price"]) * Decimal(i["qty"])) for i in doc["items"]}
allowed |= {int(i["unit_price"]) for i in doc["items"]}
with open(draft_path, encoding="utf-8") as f:
draft = f.read()
stray = sorted(amounts_in(draft) - allowed)
missing = sorted({int(total)} - amounts_in(draft))
print(f"subtotal {int(subtotal):,} / tax {int(tax):,} / total {int(total):,}")
if stray:
print("not in the source:", [f"{n:,}" for n in stray])
if missing:
print("total missing from the draft:", [f"{n:,}" for n in missing])
sys.exit(1 if stray or missing else 0)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])Save it as check_amounts.py and run:
python3 check_amounts.py quote.json draft.txtWith the JSON above, the subtotal is 210,000, tax 21,000 and total 231,000. A draft that says 231,000 passes. A draft with 213,000 — two digits swapped — stops with two lines: one number not in the source, and the total missing. Swapped digits are exactly the kind of slip eyes slide past and a script catches.
Why round in one place only
Computing tax per line and summing can leave the total off by one yen, which is why calc rounds in a single spot.
Whether you round down or to nearest depends on your agreement with the client and the format you use. I round down here; swap the single rounding= line to match your own practice. What matters is deciding where the rounding happens once — the same idea as not scattering amounts through the prose.
Small rules that settled in
As an indie developer who gradually started taking on client sites, I've accumulated a few rules:
- Amounts change only in
quote.json; I never ask the chat to "redo it with this amount" - Client and contact names are masked when I ask Claude for wording
- Once the script passes, I read the whole thing aloud one time
The third stays my job, not the script's. Reading aloud tends to surface "would this phrase sound off to the client?" before it surfaces any number problem. Numbers belong to the source file, words to Claude, the last read to me. I try to keep that split even in busy weeks.
What to do next
Pick one quote you sent recently and copy only its items and prices into quote.json. For the next one, start from a request that leaves the "[TOTAL]" hole open — I think you'll feel the difference right away.