Assigns spending categories to uncategorized transactions using aggregate GROUP BY queries. Writes to the inferred_category overlay field, preserving explicit category values. Memory-backed for auto-applying known merchant→category mappings on future runs. Should run after merchant normalization.
This skill guides the finance butler through assigning spending categories to
transactions that lack an explicit category. Categories are inferred per-merchant
and stored in the inferred_category metadata overlay field. The original
category field (set during CSV import or email ingestion) is never modified.
READ THIS SECTION FIRST. These are hard rules, not suggestions.
You MUST NOT call list_transactions or any other tool that reads individual
transaction rows into context at any point during this workflow.
Reason: Categorization is per-merchant, not per-transaction. "Whole Foods" is always "groceries" regardless of which specific transaction. A typical account has hundreds or thousands of transaction rows — reading them into context would exhaust the token budget and likely abort the session before any categories are applied.
The only acceptable data source is , which returns a compact GROUP BY result — typically 50–200 rows even for a year of transactions.
list_distinct_merchantsYou MUST NOT attempt to categorize by reading transactions and writing them back one row at a time. This is both token-prohibitive and semantically wrong — category inference is a metadata overlay, not a re-ingestion.
All merchant data used in this workflow comes from list_distinct_merchants. This
tool returns deduplicated merchant names with transaction counts and totals. It is
the correct and only acceptable source for categorization input.
If the first call returns total > 500, you MUST process in paginated batches.
Use limit=500, offset=0, then limit=500, offset=500, etc., until all merchants
are categorized. Do not skip pagination — large accounts can have hundreds of
distinct merchant names.
Category inference MUST write to the inferred_category metadata field via
bulk_update_transactions. It MUST NOT overwrite the original category field,
and MUST NOT touch subject, predicate, content, or embedding columns.
The finance butler's fact layer is write-once on core columns. Category inference uses the metadata overlay pattern instead:
| Field | Role |
|---|---|
category | Original explicit category from CSV or email ingestion — never modified |
inferred_category | LLM-assigned category set by this skill — preferred when no explicit category exists |
Query and display tools (spending_summary, list_transactions, dashboard) apply
the following precedence when resolving a transaction's effective category:
category (explicit) > inferred_category (LLM-assigned) > uncategorized
You do not need to update any query logic — the overlay is transparently consumed.
Use exactly these category labels. Do not invent new categories.
| Label | Examples |
|---|---|
groceries | Supermarkets, grocery delivery, wholesale clubs (Costco, Sam's Club) |
dining | Restaurants, cafes, bars, food delivery (Uber Eats, DoorDash, Grubhub) |
transport | Gas stations, ride-share (Uber, Lyft), parking, tolls, public transit, auto services |
subscriptions | Streaming (Netflix, Spotify, Disney+), SaaS, news, cloud storage |
entertainment | Movie theaters, concerts, events, gaming, amusement parks |
utilities | Electricity, gas, water, internet, mobile phone, waste collection |
healthcare | Pharmacies, doctors, dentists, labs, insurance premiums, gym (when wellness-focused) |
shopping | Retail (Amazon, Target, department stores), online marketplaces, home goods |
travel | Hotels, airlines, travel agencies, car rentals, vacation bookings |
education | Tuition, course platforms (Coursera, Udemy), textbooks, tutoring |
personal | Haircuts, beauty, spa, laundry, personal care not covered elsewhere |
other | Anything that does not fit the above — use sparingly; prefer a specific label |
This skill SHOULD run AFTER merchant normalization. Running after normalization
means distinct merchants are already collapsed into canonical names (e.g., the
three Whole Foods store variants are a single "Whole Foods" entry). This reduces
the merchant list size and produces more accurate, consistent categorization.
Call list_distinct_merchants to get the compact merchant list:
result = list_distinct_merchants(min_count=1)
Use normalized_merchant values when available (post-normalization). The tool
returns normalized_merchant in each entry when the overlay has been set.
Inspect the response:
total — total number of distinct merchantsmerchants — list of {merchant, normalized_merchant (if set), count, total_amount} entriestotal > 500, proceed in paginated batches (see CRITICAL CONSTRAINTS)If total == 0, report "No merchants found." and stop.
Before running any LLM inference, check whether this butler has seen and categorized any of these merchants before:
memory_search(
query="merchant category inference",
tags=["merchant-category"],
limit=100,
)
Also try targeted recall for high-frequency merchants you see in the list:
memory_recall(topic="merchant_category:Netflix")
Memory fact schema for merchant categories (written in Step 7):
subject: "merchant_category:<CANONICAL_NAME>" — the clean canonical merchant namepredicate: "merchant_category"content: "subscriptions" — the assigned category labeltags: ["merchant-category", "category-inference", "<category-label>"]Collect all known mappings into a lookup table before proceeding.
For every merchant in the list that matches a known category mapping, call
bulk_update_transactions immediately — no LLM review needed:
bulk_update_transactions(updates=[
{
"match": {"merchant_pattern": "Netflix%"},
"set": {"inferred_category": "subscriptions"},
},
{
"match": {"merchant_pattern": "Whole Foods%"},
"set": {"inferred_category": "groceries"},
},
# ... one entry per known merchant→category mapping
])
Track which merchants from the list have been handled so they are excluded from the LLM review in Step 4.
Present the remaining merchants (those not covered by known mappings) to
yourself for categorization. For each merchant, you have count (transaction
frequency) and total_amount (aggregate spend) as context.
Assign each merchant exactly one category from the standard taxonomy above. Apply these heuristics:
count descending
and prioritize the top entries; a miscategorization at 50 transactions has more
impact than one at 2 transactionsgroceries;
general merchandise retailers (Target, Walmart, Amazon) → shopping unless the
merchant is clearly a grocery-only outletdining; package
delivery companies (FedEx, UPS) → shopping (proxy for the underlying purchase)transport; if you see strong evidence of Uber Eats
charges, use diningshopping (catches most cases); do not attempt to
subcategorize Amazon purchaseshealthcare if clearly a fitness facility; subscriptions
if it is a streaming/digital wellness appother over a forced fit, and note the
ambiguity in the reportProduce a list of assignments: [(merchant_pattern, category), ...]
For each assignment from Step 4, call bulk_update_transactions:
bulk_update_transactions(updates=[
{
"match": {"merchant_pattern": "Trader Joe%"},
"set": {"inferred_category": "groceries"},
},
{
"match": {"merchant_pattern": "UBER%"},
"set": {"inferred_category": "transport"},
},
# ... one entry per merchant
])
Pattern guidance for merchant_pattern:
% matches any sequence, _ matches one character"Netflix%", "Whole Foods%""NETFLIX%", "WHOLEFDS%", "WHOLE FOODS%"Accumulate the total_matched and total_updated counts returned by each
bulk_update_transactions call.
After all updates are applied, report a concise summary:
Category inference complete.
Distinct merchants processed: 54
Auto-applied (known mappings): 8 (from memory)
LLM-categorized: 46
Transactions updated: 892
Breakdown by category:
groceries 187 (21%)
dining 162 (18%)
subscriptions 94 (11%)
shopping 201 (23%)
transport 89 (10%)
utilities 47 (5%)
healthcare 38 (4%)
entertainment 31 (3%)
other 43 (5%)
If pagination was used, note the batch count:
Processed in 2 batches (1,100 distinct merchants total).
For each new merchant→category mapping produced by LLM in Step 4 (i.e., not already in memory), store a memory fact so future runs can auto-apply it:
# Resolve or create the merchant entity first (follow memory-classification skill).