Expense categorize
Proposes an account for each uncategorised line and says why. It is a review sheet for the bookkeeper, not a posting tool. When nothing in the business's own rules or history settles a line, the answer is a question, never a guess.
Inputs
All CSV, exported by the bookkeeper (see examples/):
chart.csv: the business's expense accounts, columnaccount. Suggestions come only from this list.rules.csv: owner rules,contains, account(for exampleFUEL STOP, Fuel).history.csv: past categorised lines,date, payee, amount, account(a few months is enough).transactions.csv: the uncategorised lines,date, payee, amount.
Read ledger data through the AHL Action Gateway or the bookkeeper's export. The agent never holds the accounting or bank login and stores no credentials in files or prompts. If the firm has not approved a hosted AI tool for client ledger data, run this on a local model; the script itself is local and needs no network.
Steps
- Run the script:
node scripts/suggest.mjs examples/chart.csv examples/rules.csv examples/history.csv examples/transactions.csv --review-above 1000--review-aboveis the amount the owner or CPA names; any suggestion over it is marked "(confirm)" because a large purchase may be equipment or a prepayment rather than an expense. Leave it out if the owner has not set one. - How it decides, in order:
- payee contains a word such as TRANSFER, LOAN, ATM, ZELLE, VENMO, TAX or IRS: always ASK, since it may be a transfer, a loan, a tax payment or personal;
- an owner rule matches: that account;
- the same payee was always put in one account before: that account, with the count;
- the same payee was split across accounts, or is new: ASK. Payee text is cleaned first: long digit runs (card, store or reference numbers) are removed, so they never appear in the output.
- Write the questions. For each ASK line, one plain question the owner can answer in a sentence: "A $310.00 payment to Riverbend Auto Glass on Sept 22: which vehicle, and was it a repair?" Group several lines from one payee into one question.
- Send tax questions elsewhere. Whether something is deductible, capital (depreciated) or personal is the CPA's call. The IRS treats depreciation of business assets as its own topic, separate from everyday expenses (IRS, Guide to business expense resources). Mark such lines "CPA" and do not suggest an account.
- Learn from corrections. When the bookkeeper changes a suggestion, add the line to
history.csv. If the same correction repeats, suggest a new owner rule; the bookkeeper adds it.
Output
The script's table (compare examples/expected-output.md), then the question list:
| 2026-09-12 | HOMEBASE HARDWARE | -$88.15 | ASK | history split: Materials and supplies x1, Tools and small equipment x1 |
Question: Homebase Hardware, $88.15 on Sept 12: materials for a job, or a tool?
Remind the bookkeeper which lines lack a receipt: the IRS expects records that show the payee, amount, date, proof of payment and a description showing the expense was for the business (IRS, What kind of records should I keep).
Edge cases
- Refunds and returns (positive amounts from a supplier): suggest the same account as the original purchase, marked as a refund.
- One payment covering several things (a big box store): ASK for a split; never split it yourself.
- Payee name changed (new trading name): treat as new until the owner confirms they are the same.
- History account no longer in the chart: the script says so; ask which account replaced it.
Never
- Post, edit, or delete a transaction, or create a bank rule in the ledger.
- Suggest an account that is not in the chart.
- Decide that something is personal, an owner draw, a loan, a fixed asset or tax-deductible.
- Pass card or bank account numbers to a model or into the output.
- Fill in an ASK line with a "best guess".
Hand over
The bookkeeper reviews the sheet beside the bank feed, accepts or changes each line and posts it in their own system. They check every "(confirm)" line against the receipt, send CPA lines to the CPA, and send the question list to the owner. A CPA reviews a sample of accepted suggestions each month.
The skill folder
Every file in the folder. Download each one into a folder with the same layout, or copy it.
expense-categorize/ SKILL.md examples/chart.csv examples/expected-output.md examples/history.csv examples/rules.csv examples/transactions.csv scripts/suggest.mjs
SKILL.md
---
name: expense-categorize
description: Suggests an account from the business's own chart of accounts for each uncategorised bank or card transaction, using the owner's rules and how the same payee was categorised before, and turns everything else into plain questions for the owner. Use when a bookkeeper or owner asks to categorise a bank feed, clear the uncategorised or "ask my accountant" account, or prepare month-end questions. Suggestions only; a person posts every line.
metadata:
ahl-category: money
ahl-jobs: bookkeeping
ahl-industries: accounting-firm, plumbing, hvac, electrical, retail-shop, consulting
ahl-connectors: quickbooks-online, xero
ahl-workflows: money--1823-accounting-firm-bank-feed-category-suggestions, money--1822-accounting-firm-monthly-close-client-questions, money--01-extract-receipts-into-a-spreadsheet
ahl-human-check: The bookkeeper accepts or changes every suggestion and posts it; tax treatment questions go to the CPA.
ahl-proposed-status: approved
ahl-version: 1
ahl-sources: |
Internal Revenue Service — What kind of records should I keep — https://www.irs.gov/businesses/small-businesses-self-employed/what-kind-of-records-should-i-keep — 2026-10-06
Internal Revenue Service — Guide to business expense resources — https://www.irs.gov/businesses/small-businesses-self-employed/deducting-business-expenses — 2026-10-06
---
# Expense categorize
Proposes an account for each uncategorised line and says why. It is a review sheet for the bookkeeper, not a posting tool. When nothing in the business's own rules or history settles a line, the answer is a question, never a guess.
## Inputs
All CSV, exported by the bookkeeper (see [examples/](examples/)):
- `chart.csv`: the business's expense accounts, column `account`. Suggestions come only from this list.
- `rules.csv`: owner rules, `contains, account` (for example `FUEL STOP, Fuel`).
- `history.csv`: past categorised lines, `date, payee, amount, account` (a few months is enough).
- `transactions.csv`: the uncategorised lines, `date, payee, amount`.
Read ledger data through the AHL Action Gateway or the bookkeeper's export. The agent never holds the accounting or bank login and stores no credentials in files or prompts. If the firm has not approved a hosted AI tool for client ledger data, run this on a local model; the script itself is local and needs no network.
## Steps
1. **Run the script:**
```bash
node scripts/suggest.mjs examples/chart.csv examples/rules.csv examples/history.csv examples/transactions.csv --review-above 1000
```
`--review-above` is the amount the owner or CPA names; any suggestion over it is marked "(confirm)" because a large purchase may be equipment or a prepayment rather than an expense. Leave it out if the owner has not set one.
2. **How it decides**, in order:
- payee contains a word such as TRANSFER, LOAN, ATM, ZELLE, VENMO, TAX or IRS: always **ASK**, since it may be a transfer, a loan, a tax payment or personal;
- an owner rule matches: that account;
- the same payee was always put in one account before: that account, with the count;
- the same payee was split across accounts, or is new: **ASK**.
Payee text is cleaned first: long digit runs (card, store or reference numbers) are removed, so they never appear in the output.
3. **Write the questions.** For each ASK line, one plain question the owner can answer in a sentence: "A $310.00 payment to Riverbend Auto Glass on Sept 22: which vehicle, and was it a repair?" Group several lines from one payee into one question.
4. **Send tax questions elsewhere.** Whether something is deductible, capital (depreciated) or personal is the CPA's call. The IRS treats depreciation of business assets as its own topic, separate from everyday expenses (IRS, Guide to business expense resources). Mark such lines "CPA" and do not suggest an account.
5. **Learn from corrections.** When the bookkeeper changes a suggestion, add the line to `history.csv`. If the same correction repeats, suggest a new owner rule; the bookkeeper adds it.
## Output
The script's table (compare [examples/expected-output.md](examples/expected-output.md)), then the question list:
```
| 2026-09-12 | HOMEBASE HARDWARE | -$88.15 | ASK | history split: Materials and supplies x1, Tools and small equipment x1 |
Question: Homebase Hardware, $88.15 on Sept 12: materials for a job, or a tool?
```
Remind the bookkeeper which lines lack a receipt: the IRS expects records that show the payee, amount, date, proof of payment and a description showing the expense was for the business (IRS, What kind of records should I keep).
## Edge cases
- **Refunds and returns** (positive amounts from a supplier): suggest the same account as the original purchase, marked as a refund.
- **One payment covering several things** (a big box store): ASK for a split; never split it yourself.
- **Payee name changed** (new trading name): treat as new until the owner confirms they are the same.
- **History account no longer in the chart**: the script says so; ask which account replaced it.
## Never
- Post, edit, or delete a transaction, or create a bank rule in the ledger.
- Suggest an account that is not in the chart.
- Decide that something is personal, an owner draw, a loan, a fixed asset or tax-deductible.
- Pass card or bank account numbers to a model or into the output.
- Fill in an ASK line with a "best guess".
## Hand over
The bookkeeper reviews the sheet beside the bank feed, accepts or changes each line and posts it in their own system. They check every "(confirm)" line against the receipt, send CPA lines to the CPA, and send the question list to the owner. A CPA reviews a sample of accepted suggestions each month.
examples/chart.csv
account Bank charges Fuel Materials and supplies Rent Software subscriptions Telephone and internet Tools and small equipment Vehicle repairs
examples/expected-output.md
| Date | Payee | Amount | Suggestion | Basis | |---|---|---|---|---| | 2026-09-02 | FUEL STOP RT 9 | -$74.20 | Fuel | owner rule "FUEL STOP" | | 2026-09-03 | PIPEWORKS SUPPLY | -$356.80 | Materials and supplies | same payee 2 time(s) before | | 2026-09-05 | PIPEWORKS SUPPLY | -$1480.00 | Materials and supplies (confirm) | same payee 2 time(s) before; over $1000.00, confirm it is not equipment or a prepayment | | 2026-09-12 | HOMEBASE HARDWARE | -$88.15 | ASK | history split: Materials and supplies x1, Tools and small equipment x1 | | 2026-09-15 | CLOUDLEDGER APP | -$45.00 | Software subscriptions | same payee 2 time(s) before | | 2026-09-18 | ONLINE TRANSFER TO SAV | -$500.00 | ASK | payee contains "TRANSFER": could be a transfer, loan, tax or personal item | | 2026-09-22 | RIVERBEND AUTO GLASS | -$310.00 | ASK | new payee, no rule | | 2026-09-30 | MONTHLY SERVICE FEE | -$15.00 | Bank charges | owner rule "SERVICE FEE" | 8 lines: 2 by owner rule, 3 from history, 3 to ask. Nothing has been posted.
examples/history.csv
date,payee,amount,account 2026-07-03,PIPEWORKS SUPPLY #2231,-412.10,Materials and supplies 2026-08-06,PIPEWORKS SUPPLY #2231,-288.45,Materials and supplies 2026-07-15,CLOUDLEDGER APP,-45.00,Software subscriptions 2026-08-15,CLOUDLEDGER APP,-45.00,Software subscriptions 2026-07-20,HOMEBASE HARDWARE 0098,-61.30,Materials and supplies 2026-08-21,HOMEBASE HARDWARE 0098,-149.99,Tools and small equipment 2026-08-01,OAKRIDGE PROPERTIES,-1850.00,Rent
examples/transactions.csv
date,payee,amount 2026-09-02,FUEL STOP 4471 RT 9,-74.20 2026-09-03,PIPEWORKS SUPPLY #2231,-356.80 2026-09-05,PIPEWORKS SUPPLY #2231,-1480.00 2026-09-12,HOMEBASE HARDWARE 0098,-88.15 2026-09-15,CLOUDLEDGER APP,-45.00 2026-09-18,ONLINE TRANSFER TO SAV 99812345,-500.00 2026-09-22,RIVERBEND AUTO GLASS,-310.00 2026-09-30,MONTHLY SERVICE FEE,-15.00
scripts/suggest.mjs
#!/usr/bin/env node
// Suggests an expense account for each uncategorised transaction from the owner's
// rules and the business's own categorisation history. It never guesses: no rule
// and no consistent history means "ask".
// Usage: node scripts/suggest.mjs <chart.csv> <rules.csv> <history.csv> <transactions.csv> [--review-above 1000]
// Local files only, no network. Amounts are integer cents.
import fs from "node:fs";
// Payee words that often mean a transfer, a loan, a tax payment or personal use.
// These always go to a person, whatever the history says.
const ALWAYS_ASK = ["TRANSFER", "LOAN", "ZELLE", "VENMO", "CASH APP", "ATM", "IRS", "TAX", "WITHDRAWAL"];
function parseCsv(path) {
let text;
try { text = fs.readFileSync(path, "utf8"); } catch { console.error(`Cannot read ${path}. Check the file name.`); process.exit(2); }
const rows = [];
let row = [], field = "", q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) { if (c === '"' && text[i + 1] === '"') { field += '"'; i++; } else if (c === '"') q = false; else field += c; }
else if (c === '"') q = true;
else if (c === ",") { row.push(field); field = ""; }
else if (c === "\n" || c === "\r") { if (c === "\r" && text[i + 1] === "\n") i++; row.push(field); field = ""; if (row.some((x) => x !== "")) rows.push(row); row = []; }
else field += c;
}
if (field !== "" || row.length) { row.push(field); if (row.some((x) => x !== "")) rows.push(row); }
const [head, ...data] = rows;
return data.map((r) => Object.fromEntries(head.map((h, i) => [h.trim(), (r[i] ?? "").trim()])));
}
const cents = (s) => Math.round(Number(String(s).replace(/[$,]/g, "")) * 100);
const money = (c) => (c < 0 ? "-" : "") + "$" + (Math.abs(c) / 100).toFixed(2);
// Long digit runs (card, store or reference numbers) are removed so the same payee groups together
// and no account-like number reaches the output.
const norm = (p) => p.toUpperCase().replace(/\d{4,}/g, "").replace(/[^A-Z0-9 ]/g, " ").replace(/\s+/g, " ").trim();
const args = process.argv.slice(2);
let reviewAbove = Infinity;
const r = args.indexOf("--review-above");
if (r >= 0) { reviewAbove = cents(args[r + 1]); args.splice(r, 2); }
if (args.length < 4) { console.error("Usage: node scripts/suggest.mjs <chart.csv> <rules.csv> <history.csv> <transactions.csv> [--review-above N]"); process.exit(2); }
const [chart, rules, history, txns] = args.map(parseCsv);
const accounts = new Set(chart.map((a) => a.account));
for (const rule of rules) if (!accounts.has(rule.account)) { console.error(`Rule "${rule.contains}" points to "${rule.account}", which is not in the chart of accounts. Fix the rule first.`); process.exit(2); }
const past = new Map();
for (const h of history) { const k = norm(h.payee); if (!past.has(k)) past.set(k, new Map()); past.get(k).set(h.account, (past.get(k).get(h.account) || 0) + 1); }
console.log("| Date | Payee | Amount | Suggestion | Basis |");
console.log("|---|---|---|---|---|");
const counts = { rule: 0, history: 0, ask: 0 };
for (const t of txns) {
const p = norm(t.payee), amt = cents(t.amount);
let s = "ASK", basis;
const word = ALWAYS_ASK.find((w) => p.includes(w));
const rule = rules.find((x) => p.includes(norm(x.contains)));
const h = past.get(p);
if (word) basis = `payee contains "${word}": could be a transfer, loan, tax or personal item`;
else if (rule) { s = rule.account; basis = `owner rule "${rule.contains}"`; }
else if (h && h.size === 1) { const [acc, n] = [...h][0]; if (accounts.has(acc)) { s = acc; basis = `same payee ${n} time(s) before`; } else basis = `history uses "${acc}", not in chart`; }
else if (h) basis = `history split: ${[...h].map(([a, n]) => `${a} x${n}`).join(", ")}`;
else basis = "new payee, no rule";
if (s !== "ASK" && Math.abs(amt) > reviewAbove) { basis += `; over ${money(reviewAbove)}, confirm it is not equipment or a prepayment`; s += " (confirm)"; }
counts[s === "ASK" ? "ask" : rule && !word ? "rule" : "history"]++;
console.log(`| ${t.date} | ${p} | ${money(amt)} | ${s} | ${basis} |`);
}
console.log(`\n${txns.length} lines: ${counts.rule} by owner rule, ${counts.history} from history, ${counts.ask} to ask. Nothing has been posted.`);
Use it in Claude Code
Put the folder at .claude/skills/expense-categorize/ in your project (or ~/.claude/skills/expense-categorize/ to use it in every project). Claude Code finds SKILL.md there and uses the skill when a request matches its description.
Other agents that read the Agent Skills format take the same folder.
Sources
- Internal Revenue Service: What kind of records should I keepChecked 6 Oct 2026
- Internal Revenue Service: Guide to business expense resourcesChecked 6 Oct 2026