A primer on the Breadbox rule DSL — the doctrine behind it, how to build condition trees, what actions are available, and worked examples you can adapt.
Rules are the workhorse of any Breadbox setup. A well-tuned set of rules can categorize the overwhelming majority of your transactions during sync, leaving only the genuinely ambiguous ones for a human or agent to look at. This guide walks through the DSL by example — five rules you can adapt and drop into your instance today.If you haven’t yet, skim Breadbox in a nutshell first for the vocabulary. The full specification lives in Rules: the substrate that turns provider data into intelligence and the rules API reference.
The doctrine: provider data is immutable; intelligence accrues as rules
Breadbox treats provider data — the raw transactions Plaid, Teller, SimpleFIN, or your CSV imports drop into the database — as a permanent, untouched substrate. Breadbox never rewrites the provider’s name, merchant, amount, or categories. Every durable choice you or an agent makes about a charge — “this is a subscription”, “this is a grocery run”, “flag anything over $1000” — accrues as a rule. The next sync resolves the same kind of charge the same way automatically, without a re-run.Two practical consequences:
Match on raw fields. A rule keyed on provider_name, provider_merchant_name, amount, pending, or the date-parts (day_of_month, month, …) resolves identically on every sync and on retroactive apply. A rule keyed on a mutable label (account_name, category, tags) is reacting to something a person, agent, or earlier-stage rule can change.
Last-writer-wins. Rules, agents, and users all write the same fields (category_id, tags, metadata, series link, counterparty link, flag). There is no per-source precedence guard — whoever runs last wins. The sync engine only runs rules on new or changed transactions, so a user’s manual edit on an unchanged row is not silently re-clobbered.
A rule is a JSON document with three important pieces:
A condition — a recursive tree of leaves (field/op/value) and combinators (and / or / not).
One or more actions — set_category, add_tag, remove_tag, set_metadata, remove_metadata, assign_series, assign_counterparty, flag, unflag, or add_comment.
A trigger and stage — when the rule runs (, always, on_change) and where in the pipeline (baseline, standard, refinement, override).
Amounts use Plaid convention: positive = money out (purchases, payments), negative = money in (refunds, paychecks). Every example below respects that.
The canonical “merchant name contains a substring” rule. Two conditions combined with and: the provider’s raw description must contain AMAZON, and the amount must be positive (so we don’t catch Amazon refunds).
When a single condition isn’t enough, an or group handles the “any of these merchants” case. Here we use the in operator on provider_merchant_name to check against a list in one shot.
provider_merchant_name is only populated for providers that enrich the raw description (Plaid does this; Teller and CSV imports often don’t). If your data comes primarily from Teller, prefer provider_name with contains and match on a substring of the raw description.
Example 3 — Threshold flag: surface anything over $1000
Not every rule has to change the category. This one only flags a charge for human attention — anything over $1000 goes into the flagged queue so you can review it before the rest.
flag sets flagged_at = NOW() on the matching transaction. Retrieve flagged rows with query_transactions(flagged=true) from MCP, GET /transactions?flagged=true from the API, or the flagged filter in the dashboard. A follow-up unflag rule (or a manual review) clears the flag once you’re done with it.
Example 4 — Tag a subscription via the recurrence idiom
Recurring charges have a stable signature: the same merchant, near the same amount, near the same day of the month. amount approx and day_of_month approx capture that in two conditions — and assign_series makes every future match join the same series automatically.
amount approx 10.99 ± 1.00 keeps matching when a 10.99planticksupto11.99. day_of_month approx 14 ± 2 is cyclic and clamped — day 1 and the month’s last day are 1 apart, and a target past a short month clamps to the last day (so “the 31st” matches February). This pair is the backbone of every recurring-charge rule.assign_series mints the series the first time a charge matches and links every future match to it. See Tracking subscriptions for the full series story.
You can freely nest and, or, and not up to 10 levels deep. For most workflows you’ll keep it under three.
If a transaction gets the wrong category anyway, set it manually from the dashboard. Last-writer-wins applies — your manual edit replaces the rule’s choice. The sync engine only re-runs rules on new or changed transactions, so a deliberate manual edit on an unchanged row stays in place across syncs.
Rules aren’t just for set_category. The same condition tree feeds every other action:
set_metadata / remove_metadata — write any household-specific enrichment to a transaction’s free-form metadata blob. Mark anything Whole Foods as tax_deductible: false; tag every charge from your trip account with trip: "japan-2026"; record reimbursable_by: "work". A later rule can read it back via metadata.<key> conditions, and you can query on it from MCP and the API.
assign_counterparty — bind a transaction to the canonical “other side” of the charge, covering merchants and non-merchants (Venmo, people, employers). Same surrogate-first, NULL-fill semantics as assign_series.
flag / unflag — surface a transaction for human attention. The example above is the simplest version; pair unflag with a follow-up condition to auto-retire a flag once it’s resolved.
Rules only fire at sync time by default. To run a newly created rule against your full history:
# Apply one rulecurl -X POST \ -H "X-API-Key: bb_your_key" \ http://localhost:8080/api/v1/rules/rule_abc123/apply# Apply every active rulecurl -X POST \ -H "X-API-Key: bb_your_key" \ http://localhost:8080/api/v1/rules/apply-all
Retroactive apply follows the same pipeline order as live sync and materializes every state-mutating action — set_category, add_tag, remove_tag, set_metadata, remove_metadata, assign_series, assign_counterparty, flag, and unflag. One caveat: add_comment actions are skipped during retroactive apply (they’re designed to narrate a specific sync event).