Skip to main content
Breadbox’s rules engine is the single layer where your household’s intelligence about transactions lives. Provider data (Plaid, Teller, CSV, …) is treated as immutable — it’s the raw substrate. Every durable choice you or an agent makes about that data — “Spotify is a subscription”, “any Whole Foods over 50isagroceryrun,notpreparedfood","flaganysinglechargeover50 is a grocery run, not prepared food"*, *"flag any single charge over 1000” — accrues as a rule. The next sync resolves the same kind of charge the same way automatically. That’s the doctrine: provider data is immutable; intelligence accrues as rules. A rule is a JSON document pairing a condition (which transactions to match) with one or more actions (what to do with them). Rules run at sync time in a defined pipeline order, and you can apply them retroactively to your full history at any time. A single rule can:
  • Set a transaction’s category (set_category)
  • Add or remove tags (add_tag, remove_tag)
  • Write or delete free-form metadata keys (set_metadata, remove_metadata)
  • Link the transaction to a recurring series (assign_series)
  • Link the transaction to a counterparty (assign_counterparty)
  • Raise or clear the flag that surfaces a charge for human attention (flag, unflag)
  • Leave an automated comment explaining the categorization (add_comment)

Condition structure

Rule conditions use a recursive tree of leaf nodes and combinators. A leaf node tests a single field:
A combinator groups multiple conditions with logic:
Combinators nest to any depth up to 10 levels. An empty condition object {} matches every transaction.

Available fields

FieldTypeDescription
provider_namestringRaw transaction description from the institution
provider_merchant_namestringProvider’s enriched merchant name (may be empty for CSV or un-enriched rows)
amountnumericTransaction amount — positive = money out, negative = money in
provider_category_primarystringProvider’s raw primary category (does not change when Breadbox reassigns)
provider_category_detailedstringProvider’s raw detailed subcategory
categorystringCurrently assigned Breadbox category slug (updates mid-pipeline as rules run)
pendingbooleanWhether the transaction is pending
providerstringplaid, teller, simplefin, or csv
account_idstringAccount UUID
account_namestringAccount display name
user_idstringFamily member UUID
user_namestringFamily member display name
tagstagsCurrent tag slugs on the transaction
seriesstringshort_id of the recurring series the transaction belongs to (empty when unassigned)
in_seriesbooleanWhether the transaction is linked to any recurring series
counterpartystringshort_id of the counterparty bound to the transaction (empty when unbound)
has_counterpartybooleanWhether the transaction is bound to any counterparty
day_of_monthnumericDay of the posting date (131)
monthnumericMonth of the posting date (112)
day_of_weeknumericWeekday of the posting date (0 = Sunday … 6 = Saturday)
day_of_yearnumericOrdinal day of the posting date (1366)
metadata.<key>metadataOne key from the transaction’s free-form metadata blob — e.g. metadata.tax_deductible reads metadata["tax_deductible"].
Use category (not provider_category_primary or provider_category_detailed) when you want a condition to react to the category that Breadbox or a prior rule assigned. The provider_* fields always hold the provider’s original values and never change.

Match-stability: prefer raw, immutable fields

A rule is only as durable as the fields it matches on. The provider’s raw fields (provider_name, provider_merchant_name, amount, pending, provider, provider_category_*) and the date-parts derived from the immutable posting date (day_of_month, month, day_of_week, day_of_year) are raw-immutable — Breadbox never rewrites them, so a rule keyed on them resolves the same way on the create pass, on every re-sync, and on retroactive apply. The surrogate IDs account_id and user_id are stable-surrogate — also safe. account_name, user_name, category, tags, series, in_series, counterparty, has_counterparty, and metadata.<key> are mutable-display — they either silently break when something is renamed or depend on what an earlier-stage rule wrote in the same pass. Use them deliberately to chain off another rule’s output, not as the load-bearing condition that decides whether the rule fires.

Operators by field type

String fields (provider_name, provider_merchant_name, provider_category_primary, provider_category_detailed, category, provider, account_name, user_name, series, counterparty):
OperatorBehavior
eqExact match (case-insensitive)
neqNot equal (case-insensitive)
containsSubstring match (case-insensitive)
not_containsSubstring does not match (case-insensitive)
matchesRE2 regex match (case-sensitive by default; use (?i) for insensitive)
inValue is in the provided array (case-insensitive)
Numeric fields (amount, date-parts):
OperatorBehavior
eqEqual
neqNot equal
gt / gte / lt / lteStandard comparisons
approxvalue ± tolerance — matches when abs(actual - value) ≤ tolerance. Requires a sibling tolerance ≥ 0. For day_of_month the comparison is cyclic (day 1 and the month’s last day are 1 apart) and clamps a target past a short month to its last day (so “the 31st” matches February).
betweenmin ≤ actual ≤ max (inclusive). Requires sibling min and max.
Boolean fields (pending, in_series, has_counterparty):
OperatorBehavior
eqEqual to true or false
neqNot equal to true or false
Tag field (tags):
OperatorBehavior
containsTransaction has the specified tag slug
not_containsTransaction does not have the specified tag slug
inTransaction has any of the slugs in the provided array
Metadata fields (metadata.<key>):
OperatorBehavior
exists / not_existsKey presence test; value is ignored
eq / neqBoolean, numeric, or stringified comparison driven by the expected value’s type
contains / not_contains / matches / inString ops on the stringified stored value
gt / gte / lt / lteNumeric comparison; both sides must parse as numbers
Every operator other than exists / not_exists requires the key to be present — an absent key matches only not_exists. Use or of the two when you mean “missing OR different”.

Pipeline stages

Rules run in pipeline order — lower stage numbers run first. For set_category, the last matching rule wins, so higher-stage rules have the final say on categorization. For accumulator actions (add_tag, add_comment, set_metadata), every matching rule contributes.
Stage namePriorityPurpose
baseline0Foundation rules — broad, default classifications
standard10General-purpose rules (default when no stage is specified)
refinement50Reacts to output from baseline and standard rules
override100Has the final say — overrides everything below
Supply stage as a string in the request body. You can also supply a raw priority integer (0–1000) for fine-grained ordering within a stage. If you supply both, priority wins.

Rule chaining

Because rules run in pipeline order and share a mutable transaction context, later rules can react to what earlier rules did. A rule that assigns a category at stage 0 makes that category readable via field: "category" for any rule at stage 10 or higher in the same sync pass. The same holds for tags, series, counterparty, and metadata.<key> — a later rule reads what an earlier one wrote.

Creating a rule

A complete example that categorizes Amazon purchases:
Send it to the API:
A rule with no trigger specified defaults to on_create, which means it fires only on newly synced transactions. Use always to also run on re-synced changes, or on_change to run only when an existing transaction is modified.

Actions

set_category

Sets the transaction’s assigned category. At most one set_category per rule. Last-writer-wins across the pipeline — rules, agents, and users all write the same category_id field, so a higher-stage rule’s set_category overrides a lower one. Rules only run on new or changed transactions, so a user’s manual edit on an unchanged row is not continuously re-clobbered.

add_tag / remove_tag

Adds or removes a tag. Tags are auto-created on add_tag if the slug doesn’t exist. Both are idempotent. If a single sync pass would add_tag and remove_tag the same slug, they cancel — neither write hits the DB.

set_metadata / remove_metadata

Upserts or deletes one key in the transaction’s free-form JSONB metadata blob, leaving every other key untouched. metadata_value can be any JSON value (string, number, boolean, object, array); keys are ≤128 chars and values must serialize to ≤4 KiB. Repeatable — a rule can write several keys at once. Last-writer-wins per key; a same-pass set-then-remove cancels. Use it to capture arbitrary household enrichment that isn’t a first-class field: tax_deductible, trip, reimbursable_by, project_code, … A later rule can read it back via metadata.<key> conditions.

assign_series

Links the matching transaction to a recurring series. Provide exactly one of:
  • series_short_id — link to an existing series by its short ID.
  • series_name + create_if_missing: true — mint a series by name if one doesn’t already exist with that live name (surrogate-first; the same name always resolves the same series).
A transaction belongs to at most one series; if several rules try to assign one, the highest-priority rule wins. Back-linking is NULL-fill only — assign_series never steals a charge already in another series. A series is its governing rules: the membership of every series is exactly the set of charges its assign_series rules match. The admin Recurring detail page makes this explicit by listing the linked charges beside the rules that define them.

assign_counterparty

Binds the matching transaction to a counterparty — the canonical “other side” of a charge, covering merchants and non-merchants (Venmo, people, employers). Provide exactly one of counterparty_short_id or counterparty_name + create_if_missing: true. Same NULL-fill, last-writer-wins, surrogate-first semantics as assign_series. Like series, a counterparty’s membership is exactly the set of charges its assign_counterparty rules bind.

flag / unflag

Surfaces a transaction for human attention (or clears that flag). Both take no parameters. flag sets transactions.flagged_at = NOW(); unflag clears it. Last-writer-wins across the pipeline — a higher-priority unflag clears a lower-priority flag. Retrieve flagged rows with the query_transactions(flagged=true) MCP tool or GET /transactions?flagged=true. Use it to mark anything that needs eyes: large charges, suspected duplicates, foreign-currency outliers, charges from a paused subscription, …

add_comment

Appends a comment authored by the rule. Multiple rules can each contribute a comment in one sync pass. Sync-only — retroactive apply skips add_comment because comments narrate a specific sync event.

Combining actions

A rule can carry multiple actions of different types; they all fire together. Useful combinations:
ActionsUse case
set_category aloneStraightforward reclassification.
set_category + add_tagReclassify and annotate (e.g. Uber → transportation_rideshare + recurring).
assign_series + set_category + add_tagThe full subscription pattern — link the charge, categorize it, tag it.
flag + add_commentSurface a transaction with a reason recorded in the timeline.
add_tag + remove_tag (different slugs)Transition between tags (add reviewed, remove needs-review).
set_metadataCapture household-specific enrichment for later querying.
Only set_category, flag, and unflag are singleton per rule. The rest can appear multiple times (e.g. add two tags, write two metadata keys).

Previewing a rule

Before saving a rule, you can dry-run its condition against your existing transactions to see what it would match:
The response returns a match count and a sample of matched transactions. Preview evaluates the condition in isolation — it does not simulate the full pipeline.

Applying a rule retroactively

After creating or editing a rule, apply it to your full transaction history without waiting for the next sync:
Retroactive apply respects the same pipeline stage ordering as sync. Every state-mutating action materializes — set_category, add_tag, remove_tag, set_metadata, remove_metadata, assign_series, assign_counterparty, flag, and unflag — through both the single-rule and the bulk apply-all paths.
add_comment actions do not fire during retroactive apply. Comments narrate a specific sync event and are only written during live syncs.

Last-writer-wins, no provenance guard

Rules, agents, and users all write the same underlying fields (category_id, tags, metadata, series link, counterparty link, flag). There is no per-source precedence — the writer who runs last wins. The sync engine still only runs rules on new or changed transactions, so a user’s manual edit on an unchanged row is not silently re-clobbered on the next sync.
  • Rules API — REST endpoints for creating, listing, updating, deleting, applying, and previewing rules.
  • MCP: rules (read) and MCP: rules (write) — the equivalent tools agents use to create and maintain rules on your behalf.
  • Categories — how the two-level hierarchy that rules target is structured.
  • Tracking subscriptions — how assign_series builds your recurring catalog.
  • Review workflow — how the seeded needs-review rule drives the default triage queue.
Last modified on June 25, 2026