diff --git a/guides/breadbox-in-a-nutshell.mdx b/guides/breadbox-in-a-nutshell.mdx index d3a4a21..488869f 100644 --- a/guides/breadbox-in-a-nutshell.mdx +++ b/guides/breadbox-in-a-nutshell.mdx @@ -44,7 +44,7 @@ A **rule** pairs a **condition** (which transactions to match) with one or more A leaf tests a single field: ```json -{ "field": "merchant_name", "op": "contains", "value": "Starbucks" } +{ "field": "provider_merchant_name", "op": "contains", "value": "Starbucks" } ``` Combinators glue leaves together: diff --git a/guides/single-routine-reviewer.mdx b/guides/single-routine-reviewer.mdx index 5e2a4d5..9525850 100644 --- a/guides/single-routine-reviewer.mdx +++ b/guides/single-routine-reviewer.mdx @@ -17,7 +17,7 @@ Every iteration the agent does the same four things: Call `query_transactions(tags=["needs-review"], fields="core,category", limit=30)` to pull a page worth of detail. Keep batches small — one batch is the unit the agent reasons over end-to-end. - For each row the agent considers `name`, `merchant_name`, `amount`, `account_name`, and any pre-applied category. It either confirms the category, picks a new one, or defers. + For each row the agent considers `provider_name`, `provider_merchant_name`, `amount`, `account_name`, and any pre-applied category. It either confirms the category, picks a new one, or defers. Call `update_transactions` once per batch with up to 50 operations. Each op can set the category, remove the `needs-review` tag with a note, and optionally add a comment — all in a single request. diff --git a/guides/tracking-subscriptions.mdx b/guides/tracking-subscriptions.mdx index 7ada9ba..85dfd3b 100644 --- a/guides/tracking-subscriptions.mdx +++ b/guides/tracking-subscriptions.mdx @@ -1,83 +1,84 @@ --- title: "Tracking your subscriptions" -description: "Breadbox detects recurring charges automatically and groups them into Series — confirm or reject the candidates, tag a whole subscription at once, and auto-join future charges with a rule." +description: "Breadbox tracks recurring charges as Series — thin, rule-maintained groupings. Author an assign_series rule once and every future matching charge joins the series automatically." --- Subscriptions are the quiet budget-killer of every household. Streaming services nobody watches anymore, a forgotten cloud backup, a free-trial-turned-monthly-charge — they accumulate, and they're easy to miss because each one is small. -Breadbox tracks them under **Recurring** — the umbrella for *any* repeating charge, not just subscriptions: mortgage, rent, insurance, utilities, and loans land here too. A *series* groups the charges from one merchant so you can see the whole thing as one, track what it costs, and act on it in one place. A subscription is just the most familiar kind. +Breadbox tracks them under **Recurring** — the umbrella for *any* repeating charge, not just subscriptions: mortgage, rent, insurance, utilities, and loans land here too. A *series* groups the charges from one merchant so you can see the whole thing as one place. A subscription is just the most familiar kind. Recurring is in **Beta**. Find it in the admin dashboard under **Recurring** (the old `/subscriptions` URL redirects there), plus the REST API and MCP. The shapes below are stable, but expect the surface to keep growing. -## What a series is +## A series is a thin, rule-maintained entity -A series is a recurring charge pattern. Each one carries: +In Breadbox's rules-as-substrate model, a series is deliberately minimal: -- **Cadence** — `weekly`, `biweekly`, `monthly`, `quarterly`, `semiannual`, or `annual` (plus `irregular` / `unknown` for charges that don't snap cleanly). -- **Expected amount** and its `iso_currency_code`, with an amount tolerance — so a $10.99 plan that ticks up to $11.99 stays the same series. Never sum expected amounts across currencies. -- **Next expected date** — when the next charge is due. -- **Status** — `candidate`, `active`, `paused`, or `cancelled`. -- **Occurrence count** and the detection signals behind it. +- **Surrogate identity** — a stable `id` / `short_id` that survives renames. +- **`name`** — the human label you (or an agent) picked. Unique among live series. +- **`type`** — `subscription`, `bill`, `loan`, or `other`. -Every charge that belongs to the series is *linked* to it, so the series detail page shows the full history of that subscription at a glance. +That's it. There's no shipped detector, no cadence inference, no expected-amount field, no candidate/active/paused/cancelled lifecycle. **Membership is exactly the set of charges its `assign_series` rules match** — a series *is* its governing rules. -## Automatic detection +The admin Recurring detail page makes that explicit: it shows the linked charges side-by-side with the **governing rules** that define them. -You don't have to hand-build your subscription list. On each sync, Breadbox's detector looks for charges that repeat from the same merchant at a regular cadence and proposes them as **candidate** series. It normalizes merchant names to a stable signature and ignores generic descriptors — transfers, ACH, Venmo/Zelle, bill-pay, refunds, and the like — so one-off payments don't masquerade as subscriptions. +## Auto-join charges with a rule -Candidates wait for a verdict. From the **Recurring** page you can: - -- **Confirm** — yes, this is a subscription. The series becomes `active`. -- **Reject** (shown as *Not recurring*) — the rejection is *sticky*: the detector won't re-propose it at the same amount band. -- **Pause** or **Cancel** — for subscriptions you've stopped or are putting on hold. - - - A person's verdict outranks an agent's. If you confirm a series, a later agent run can't quietly reject it. - - -## Tag a whole subscription at once - -A series can carry its own tags, and **every linked charge inherits them automatically** — including charges that join later. Tag a series `streaming` once and all of its past and future transactions pick up that tag, no per-row tagging required. - -Removing a tag from the series strips only the copies it added; a tag you put on an individual charge by hand survives. - -## Auto-join future charges with a rule - -To make every future charge from a merchant join a series on sync, use the [`assign_series` rule action](/transactions/rules#assign-a-series): +To make every charge from a merchant join a series on sync, use the [`assign_series` rule action](/transactions/rules#assign_series). The recurrence idiom — `amount approx` + `day_of_month approx` — is the durable way to express a recurring pattern: ```json { "name": "Spotify → subscription series", "conditions": { "and": [ - { "field": "merchant_name", "op": "contains", "value": "Spotify" }, - { "field": "amount", "op": "gt", "value": 0 } + { "field": "provider_merchant_name", "op": "contains", "value": "Spotify" }, + { "field": "amount", "op": "approx", "value": 10.99, "tolerance": 1.00 }, + { "field": "day_of_month", "op": "approx", "value": 14, "tolerance": 2 } ] }, "actions": [ - { "type": "assign_series", "merchant_key": "spotify", "create_if_missing": true } + { "type": "assign_series", "series_name": "Spotify", "create_if_missing": true }, + { "type": "add_tag", "tag_slug": "streaming" } ], "trigger": "on_create", "stage": "standard" } ``` -`create_if_missing` mints the series the first time a matching charge appears; after that, every future charge joins it. Provide `series_short_id` instead to target a series that already exists. +How the action resolves: + +- `series_name` + `create_if_missing: true` mints the series the first time a charge matches; every future charge with the same `series_name` joins the existing one (surrogate-first — the same name always resolves the same series). +- Provide `series_short_id` instead to target a series that already exists. +- A transaction belongs to at most one series. `assign_series` is NULL-fill only — it never steals a charge already in another series. Across the pipeline, the highest-priority rule wins. + +To tag every member of the subscription (e.g. `streaming`, `work-tools`), bundle the `add_tag` action into the same rule. Members get the tag at sync time and on retroactive apply. - Applying the subscription rule **by itself** back-fills your existing charges into the series, not just future ones (see [applying a rule retroactively](/transactions/rules#applying-a-rule-retroactively)). The bulk "apply all rules" pass doesn't link series yet, so apply this rule on its own. You can also link a charge by hand or with the `assign_series` MCP tool. + Series tags as a separate, inherited surface have been removed. Tag a series by adding an `add_tag` action to the same rule that does `assign_series`, or author a separate rule that matches the same conditions. +## Back-fill existing charges + +Once the rule is in place, future syncs handle themselves. To pull in charges that already exist, apply the rule retroactively: + +```bash +curl -X POST \ + -H "X-API-Key: bb_your_key" \ + http://localhost:8080/api/v1/rules/rule_abc123/apply +``` + +Retroactive apply materializes `assign_series` (and every other state-mutating action) through both the single-rule and the bulk apply-all paths. You can also link a charge by hand from the admin Recurring page, or with the `assign_series` MCP tool for a one-off assignment. + ## Working with series from an agent The same surface is available over MCP, which is how a scheduled reviewer agent can keep your subscription list tidy: -- `list_series` (optionally `status=candidate`) to find series awaiting a verdict, then `review_series` to confirm, reject, pause, or cancel. -- `get_series` to inspect one series and the evidence behind it. -- `assign_series` to create a series detection missed, or back-link charges to an existing one. -- `add_series_tag` / `remove_series_tag` to tag a subscription and its members. +- `list_series` — list every live series (`name`, `type`, charge count). Lean by default. +- `get_series` — fetch one series' `name` and `type` by short ID or UUID. Its linked charges come from `query_transactions(series_id=...)`. +- `assign_series` — a one-off link/mint for transactions an agent has already decided about. For durable patterns, the agent should author an `assign_series` rule instead. +- `update_series` — rename a series or change its `type`. +- `unlink_series_transactions` — detach charges from a series (inverse of `assign_series`' link path). See the [MCP tools](/mcp/tools#series-subscriptions) page for the agent-facing summary. @@ -85,16 +86,17 @@ See the [MCP tools](/mcp/tools#series-subscriptions) page for the agent-facing s Once your subscriptions are series: -- **See the portfolio.** The **Recurring** page lists every active series with its cadence and expected amount. -- **Total the outflow.** Sum the expected amounts of your active monthly series (keep each `iso_currency_code` separate — don't mix currencies). -- **Spot creep.** A series tracks its last amount against the expected one, so a plan that quietly raised its price stands out. +- **See the portfolio.** The **Recurring** page lists every live series with its name, type, and charge count, and the detail view shows the linked charges plus the governing rules behind them. +- **Total the outflow.** Query the transactions linked to your active monthly series (`query_transactions(series_id=...)`) and sum their amounts. Keep each `iso_currency_code` separate — don't mix currencies. +- **Spot creep.** A `set_metadata` rule can stamp the expected price into the transaction's metadata blob; reviewing actuals against it surfaces silent price hikes. ## Deleting a series -Removing a series is non-destructive — its transactions stay exactly where they are and simply lose the series link. Your history is never deleted. +Removing a series is non-destructive — its transactions stay exactly where they are and simply lose the series link. Your history is never deleted. To stop new charges from joining, disable or delete the `assign_series` rules that govern it. ## Related reading - [Rules](/transactions/rules) — the `assign_series` action and the full rule DSL. +- [Understanding rules](/guides/understanding-rules) — the recurrence idiom (`amount approx` + `day_of_month approx`) explained. - [MCP tools](/mcp/tools#series-subscriptions) — the series tools an agent can call. - [On-demand analysis](/guides/on-demand-analysis) — ask Claude "which of my subscriptions are underused?" once they're tracked. diff --git a/guides/understanding-rules.mdx b/guides/understanding-rules.mdx index fc0286e..0a2bf9a 100644 --- a/guides/understanding-rules.mdx +++ b/guides/understanding-rules.mdx @@ -1,32 +1,41 @@ --- title: "Understanding rules" -description: "A primer on the Breadbox rule DSL — how to build condition trees, what actions are available, and four worked examples you can adapt." +description: "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 — four rules you can adapt and drop into your instance today. +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](/guides/breadbox-in-a-nutshell) first for the vocabulary. The full specification lives in [Auto-categorize transactions with rules](/transactions/rules) and the [rules API reference](/api/overview). +If you haven't yet, skim [Breadbox in a nutshell](/guides/breadbox-in-a-nutshell) first for the vocabulary. The full specification lives in [Rules: the substrate that turns provider data into intelligence](/transactions/rules) and the [rules API reference](/api/overview). + +## 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. ## The DSL, in one screen A rule is a JSON document with three important pieces: 1. **A condition** — a recursive tree of leaves (`field`/`op`/`value`) and combinators (`and` / `or` / `not`). -2. **One or more actions** — `set_category`, `add_tag`, `remove_tag`, `add_comment`, or `assign_series` (link the charge to a recurring [series](/guides/tracking-subscriptions)). +2. **One or more actions** — `set_category`, `add_tag`, `remove_tag`, `set_metadata`, `remove_metadata`, `assign_series`, `assign_counterparty`, `flag`, `unflag`, or `add_comment`. 3. **A trigger and stage** — when the rule runs (`on_create`, `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. ## Example 1 — Amazon purchases → Shopping -The canonical "merchant name contains a substring" rule. Two conditions combined with `and`: the description must contain `AMAZON`, and the amount must be positive (so we don't catch Amazon refunds). +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). ```json { "name": "Amazon purchases", "conditions": { "and": [ - { "field": "name", "op": "contains", "value": "AMAZON" }, + { "field": "provider_name", "op": "contains", "value": "AMAZON" }, { "field": "amount", "op": "gt", "value": 0 } ] }, @@ -57,7 +66,7 @@ curl -X POST \ -d '{ "conditions": { "and": [ - { "field": "name", "op": "contains", "value": "AMAZON" }, + { "field": "provider_name", "op": "contains", "value": "AMAZON" }, { "field": "amount", "op": "gt", "value": 0 } ] } @@ -67,14 +76,14 @@ curl -X POST \ ## Example 2 — Uber / Lyft / Waymo → Transportation -When a single condition isn't enough, an `or` group handles the "any of these merchants" case. Here we use the `in` operator on `merchant_name` to check against a list in one shot. +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. ```json { "name": "Ridesharing → Transportation", "conditions": { "and": [ - { "field": "merchant_name", "op": "in", "value": ["Uber", "Lyft", "Waymo"] }, + { "field": "provider_merchant_name", "op": "in", "value": ["Uber", "Lyft", "Waymo"] }, { "field": "amount", "op": "gt", "value": 0 } ] }, @@ -88,33 +97,61 @@ When a single condition isn't enough, an `or` group handles the "any of these me ``` - `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 `name` with `contains` and match on a substring of the raw description. + `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 tag: flag anything over $500 +## Example 3 — Threshold flag: surface anything over $1000 -Not every rule has to change the category. This one only _tags_ — anything over $500 gets the `high-amount` tag so you can scan the queue for big-ticket items before the rest. +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. ```json { - "name": "Flag transactions over $500", + "name": "Flag transactions over $1000", "conditions": { "and": [ - { "field": "amount", "op": "gt", "value": 500 }, + { "field": "amount", "op": "gt", "value": 1000 }, { "field": "pending", "op": "eq", "value": false } ] }, "actions": [ - { "type": "add_tag", "tag_slug": "high-amount" } + { "type": "flag" }, + { "type": "add_comment", "content": "Flagged automatically: over $1000." } + ], + "trigger": "on_create", + "stage": "standard" +} +``` + +`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. + +```json +{ + "name": "Spotify → subscription series", + "conditions": { + "and": [ + { "field": "provider_merchant_name", "op": "contains", "value": "Spotify" }, + { "field": "amount", "op": "approx", "value": 10.99, "tolerance": 1.00 }, + { "field": "day_of_month", "op": "approx", "value": 14, "tolerance": 2 } + ] + }, + "actions": [ + { "type": "assign_series", "series_name": "Spotify", "create_if_missing": true }, + { "type": "add_tag", "tag_slug": "streaming" } ], "trigger": "on_create", "stage": "standard" } ``` -You'd pair this with a tag you've pre-created in the dashboard (**Tags** → **New tag**, slug `high-amount`). Then filter to `/transactions?tags=high-amount` in the UI, or `query_transactions(tags=["high-amount"])` from an agent. +`amount approx 10.99 ± 1.00` keeps matching when a $10.99 plan ticks up to $11.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](/guides/tracking-subscriptions) for the full series story. -## Example 4 — A `not` rule: auto-categorize groceries, but not Whole Foods prepared food +## Example 5 — A `not` rule: auto-categorize groceries, but not Whole Foods prepared food Sometimes the cleanest way to express a rule is "match this _except_ for these cases." The `not` combinator wraps a sub-condition and inverts it. @@ -123,8 +160,8 @@ Sometimes the cleanest way to express a rule is "match this _except_ for these c "name": "Grocery stores → Groceries (except prepared food)", "conditions": { "and": [ - { "field": "merchant_name", "op": "in", "value": ["Whole Foods", "Trader Joe's", "Safeway"] }, - { "not": { "field": "name", "op": "contains", "value": "PREPARED" } } + { "field": "provider_merchant_name", "op": "in", "value": ["Whole Foods", "Trader Joe's", "Safeway"] }, + { "not": { "field": "provider_name", "op": "contains", "value": "PREPARED" } } ] }, "actions": [ @@ -138,9 +175,17 @@ Sometimes the cleanest way to express a rule is "match this _except_ for these c 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. That sets `category_override = 'user'`, which the rule engine honors forever — your deliberate choice won't be undone by a later rule. + 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. +## Beyond categorization — what else rules do + +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.` 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. + ## Applying rules retroactively Rules only fire at sync time by default. To run a newly created rule against your full history: @@ -157,10 +202,10 @@ curl -X POST \ http://localhost:8080/api/v1/rules/apply-all ``` -Retroactive apply follows the same pipeline order and the same `category_override` protection as live sync. One caveat: `add_comment` actions are skipped during retroactive apply (they're designed to narrate a specific sync event). +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). ## Where to go next - [Single Routine Reviewer](/guides/single-routine-reviewer) — put these rules to work with an agent that clears the remaining `needs-review` queue on a schedule. -- [Tracking Subscriptions](/guides/tracking-subscriptions) — a worked rule example for tagging recurring charges. +- [Tracking subscriptions](/guides/tracking-subscriptions) — how `assign_series` builds your recurring catalog from rules alone. - [Rules reference](/transactions/rules) and [Rules API](/api/overview) — full DSL, every field and operator, plus the JSON shape of every endpoint. diff --git a/mcp/reference/rules-write.mdx b/mcp/reference/rules-write.mdx index be54ad0..5fc0514 100644 --- a/mcp/reference/rules-write.mdx +++ b/mcp/reference/rules-write.mdx @@ -32,9 +32,14 @@ Create a transaction rule. Rules match condition trees against transactions duri - `{"type": "set_category", "category_slug": "..."}` - `{"type": "add_tag", "tag_slug": "..."}` - `{"type": "remove_tag", "tag_slug": "..."}` + - `{"type": "set_metadata", "metadata_key": "...", "metadata_value": }` + - `{"type": "remove_metadata", "metadata_key": "..."}` + - `{"type": "assign_series", "series_short_id": "..."}` or `{"type": "assign_series", "series_name": "...", "create_if_missing": true}` + - `{"type": "assign_counterparty", "counterparty_short_id": "..."}` or `{"type": "assign_counterparty", "counterparty_name": "...", "create_if_missing": true}` + - `{"type": "flag"}` / `{"type": "unflag"}` — no parameters - `{"type": "add_comment", "content": "..."}` - Actions compose — a rule can set a category, add a tag, and add a comment on the same match. `add_comment` fires only at sync time (not on retroactive apply). If omitted, supply `category_slug` as a shorthand. + Actions compose — a rule can set a category, add a tag, write metadata, and link a series on the same match. `add_comment` fires only at sync time (not on retroactive apply); every other action materializes through both sync and retroactive paths. If `actions` is omitted, supply `category_slug` as a shorthand. @@ -58,16 +63,16 @@ Create a transaction rule. Rules match condition trees against transactions duri - If `true`, immediately apply this rule to existing transactions after creation. Materializes `set_category`, `add_tag`, `remove_tag`; skips `add_comment` (sync-only). + If `true`, immediately apply this rule to existing transactions after creation. Materializes every state-mutating action (`set_category`, `add_tag`, `remove_tag`, `set_metadata`, `remove_metadata`, `assign_series`, `assign_counterparty`, `flag`, `unflag`); skips `add_comment` (sync-only). ### Example input ```json { - "name": "name: Starbucks → food_and_drink_coffee", + "name": "Starbucks → food_and_drink_coffee", "conditions": { - "field": "merchant_name", + "field": "provider_merchant_name", "op": "contains", "value": "starbucks" }, @@ -85,9 +90,9 @@ Create a transaction rule. Rules match condition trees against transactions duri { "rule": { "id": "r9Xm2pQr", - "name": "name: Starbucks → food_and_drink_coffee", + "name": "Starbucks → food_and_drink_coffee", "conditions": { - "field": "merchant_name", + "field": "provider_merchant_name", "op": "contains", "value": "starbucks" }, @@ -224,7 +229,7 @@ Each item follows the same shape as `create_transaction_rule`. Returns created r "name": "Tag coffee shops", "stage": "baseline", "conditions": { - "field": "merchant_name", + "field": "provider_merchant_name", "op": "contains", "value": "starbucks" }, @@ -287,20 +292,25 @@ Rules fire in priority-ASC order during each sync pass, and within a single pass Rules of thumb: -- Per-merchant rules (priority 20–30 or `refinement`) > name-pattern rules (`standard`) > `category_primary` rules (`baseline`). +- Per-merchant rules (priority 20–30 or `refinement`) > name-pattern rules (`standard`) > `provider_category_primary` rules (`baseline`). - Prefer `contains` over exact match — bank feeds format merchant names inconsistently. - Always use `category_slug`, not `category_id`, when authoring actions or filters. ## Condition grammar -Same grammar used by [`preview_rule`](/mcp/reference/rules#preview_rule). +Same grammar used by [`preview_rule`](/mcp/reference/rules#preview_rule). For the full reference (every operator, the match-stability contract, and the recurrence idiom), see [Rules](/transactions/rules). -- **Fields** — `name`, `merchant_name`, `amount`, `category_primary`, `category_detailed`, `category` (assigned slug, live-updated by earlier-stage rules), `pending`, `provider`, `account_id`, `account_name`, `user_id`, `user_name`, `tags`. +- **Fields** — + - **Raw-immutable** (safe to author on): `provider_name`, `provider_merchant_name`, `amount`, `provider_category_primary`, `provider_category_detailed`, `pending`, `provider`. + - **Stable-surrogate** (safe): `account_id`, `user_id`. + - **Stable-derived** date-parts (safe): `day_of_month`, `month`, `day_of_week`, `day_of_year`. + - **Mutable-display** (use deliberately, not as a load-bearing match): `account_name`, `user_name`, `category` (assigned slug, live-updated by earlier-stage rules), `tags`, `series`, `in_series`, `counterparty`, `has_counterparty`, `metadata.`. - **Operators** — - - String/category: `eq`, `neq`, `contains`, `not_contains`, `matches` (RE2), `in`. - - Numeric: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`. + - String: `eq`, `neq`, `contains`, `not_contains`, `matches` (RE2), `in`. + - Numeric: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `approx` (`value ± tolerance`), `between` (`min`/`max`). - Bool: `eq`, `neq`. - Tags: `contains`, `not_contains`, `in`. + - Metadata (`metadata.`): all string and numeric operators above, plus `exists` / `not_exists`. - **Combinators** — `and`, `or`, `not` (nest freely, max depth 10). Nested example: @@ -310,7 +320,7 @@ Nested example: "or": [ { "and": [ - { "field": "merchant_name", "op": "contains", "value": "starbucks" }, + { "field": "provider_merchant_name", "op": "contains", "value": "starbucks" }, { "field": "amount", "op": "gte", "value": 5 } ] }, @@ -318,3 +328,15 @@ Nested example: ] } ``` + +Recurrence idiom — match a subscription by amount and day-of-month: + +```json +{ + "and": [ + { "field": "provider_merchant_name", "op": "contains", "value": "spotify" }, + { "field": "amount", "op": "approx", "value": 10.99, "tolerance": 1.00 }, + { "field": "day_of_month", "op": "approx", "value": 14, "tolerance": 2 } + ] +} +``` diff --git a/mcp/reference/rules.mdx b/mcp/reference/rules.mdx index 5e6b130..68e5bdb 100644 --- a/mcp/reference/rules.mdx +++ b/mcp/reference/rules.mdx @@ -57,9 +57,9 @@ Lists transaction rules with optional filters. Agents should always call this be "rules": [ { "id": "r9Xm2pQr", - "name": "name: Starbucks → food_and_drink_coffee", + "name": "Starbucks → food_and_drink_coffee", "conditions": { - "field": "merchant_name", + "field": "provider_merchant_name", "op": "contains", "value": "starbucks" }, @@ -192,7 +192,7 @@ Dry-run a condition tree against existing transactions — no writes, no hit-cou { "conditions": { "and": [ - { "field": "merchant_name", "op": "contains", "value": "starbucks" }, + { "field": "provider_merchant_name", "op": "contains", "value": "starbucks" }, { "field": "amount", "op": "gte", "value": 5 } ] }, @@ -239,14 +239,19 @@ Dry-run a condition tree against existing transactions — no writes, no hit-cou ### Condition grammar -Full DSL is in the Breadbox repo's `docs/rule-dsl.md`. Short form: +Full DSL lives in [Rules](/transactions/rules). Short form: -- **Fields** — `name`, `merchant_name`, `amount`, `category_primary`, `category_detailed`, `category` (the assigned slug, live-updated by earlier-stage rules), `pending`, `provider`, `account_id`, `account_name`, `user_id`, `user_name`, `tags`. +- **Fields** — + - **Raw-immutable** (safe to author on): `provider_name`, `provider_merchant_name`, `amount`, `provider_category_primary`, `provider_category_detailed`, `pending`, `provider`. + - **Stable-surrogate**: `account_id`, `user_id`. + - **Stable-derived** date-parts: `day_of_month`, `month`, `day_of_week`, `day_of_year`. + - **Mutable-display** (use for chaining, not as a primary match): `account_name`, `user_name`, `category` (assigned slug, live-updated by earlier-stage rules), `tags`, `series`, `in_series`, `counterparty`, `has_counterparty`, `metadata.`. - **Operators** — - - String/category: `eq`, `neq`, `contains`, `not_contains`, `matches` (RE2), `in`. - - Numeric: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`. + - String: `eq`, `neq`, `contains`, `not_contains`, `matches` (RE2), `in`. + - Numeric: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `approx` (`value ± tolerance`), `between` (`min`/`max`). - Bool: `eq`, `neq`. - Tags: `contains`, `not_contains`, `in`. + - Metadata: all string and numeric operators, plus `exists` / `not_exists`. - **Combinators** — `and`, `or`, `not` (nest freely, max depth 10). Pass `{}` (or omit conditions entirely on rule creation) to match every transaction. @@ -283,7 +288,7 @@ Reports which existing active rules already match a given transaction or merchan "rules": [ { "short_id": "r9Xm2pQr", - "name": "name: Starbucks → food_and_drink_coffee", + "name": "Starbucks → food_and_drink_coffee", "sets_category": "food_and_drink_coffee", "trigger": "on_create", "priority": 10, diff --git a/mcp/tools.mdx b/mcp/tools.mdx index 58421a3..d546fc3 100644 --- a/mcp/tools.mdx +++ b/mcp/tools.mdx @@ -343,22 +343,28 @@ None. Pass an empty object. ## Series (subscriptions) -Breadbox detects recurring charges and groups them into [series](/guides/tracking-subscriptions). These tools are the agent-facing side of the **Recurring** page — they let a scheduled reviewer adjudicate what the detector proposed and fill in anything it missed. (The tool *names* stay `*_series` for API stability even though the surface is now called Recurring.) +A [series](/guides/tracking-subscriptions) is a thin, rule-maintained entity — a surrogate identity, a `name`, and a `type` (`subscription`, `bill`, `loan`, or `other`). Membership comes from `assign_series` **rules**, not a shipped detector: a series *is* its governing rules. (The tool *names* stay `*_series` for API stability even though the surface is called Recurring.) | Tool | Scope | What it does | | --- | --- | --- | -| `list_series` | Read | List detected series. Filter by `status` (`active`, `candidate`, `paused`, `cancelled`); `candidate` surfaces the ones awaiting a verdict. Each row carries cadence, expected amount + currency, next expected date, and the detection signals. | -| `get_series` | Read | Fetch one series by ID, including the full evidence the detector used. | -| `explain_series_candidates` | Read | Answer "why isn't *X* recurring?" — lists recurring-looking merchants the precision-first detector skipped, with the reason (too few occurrences, irregular cadence, unstable amount, …). | -| `review_series` | Write | Apply a verdict: `confirm`, `reject` (sticky — never re-proposed at that amount band), `pause`, or `cancel`. A user's prior confirmation outranks a later agent verdict. | -| `update_series` | Write | Edit a series' user-owned attributes: name, expected amount, cadence, expected day, category, or owner. Every field is optional; omit to leave unchanged. Edits survive future detection runs (the detector won't revert them). | -| `set_series_type` | Write | Set the series type: `subscription`, `bill`, `loan`, or `other`. Overrides the type the detector inferred from the charges' category; the override is sticky. | -| `assign_series` | Write | Create a series the detector missed, or back-link transactions to an existing one. Never steals a charge already in another series. | -| `unlink_series_transactions` | Write | Detach transactions from a series (inverse of `assign_series`). Clears the series link and strips inherited tags from the affected charges only. | -| `rekey_series` | Write | Fix a series grouped under a wrong or fallback merchant key (e.g. `payment` → `spotify`), repointing it and its charges. | -| `split_series` | Write | Break an over-grouped series in two — move stray charges into a new series (e.g. a $4.99 add-on swept into a $139/yr renewal). | -| `add_series_tag` / `remove_series_tag` | Write | Tag a series; every linked transaction inherits the tag automatically. Removing strips only the inherited copies. | +| `list_series` | Read | List every live series — `name`, `type`, and charge count. Lean by default; pass `fields=all` for timestamps. | +| `get_series` | Read | Fetch one series' `name` and `type` by short ID or UUID. Its linked charges come from `query_transactions(series_id=...)`; its governing rules appear on the admin Recurring detail page. | +| `assign_series` | Write | A **one-off** link/mint — bind transactions to an existing series (`series_id`) or mint/resolve by `series_name` + `create_if_missing:true`. For durable patterns, author an `assign_series` rule instead. Optional `type` for a minted series. NULL-fill only — never steals a charge already in another series. | +| `update_series` | Write | Rename a series or change its `type`. Both optional. Renaming onto an existing live name is rejected (the name is the series' unique mint key). | +| `unlink_series_transactions` | Write | Detach transactions from a series (inverse of `assign_series`' link path). Errors if any listed transaction isn't a current member. | - A typical reviewer agent calls `list_series(status="candidate")`, inspects each with `get_series`, and resolves it with `review_series` — the same confirm/reject loop a person runs on the Recurring page. + Series tags as a separate, inherited surface have been removed. Tag a series by adding an `add_tag` action to the same rule that does `assign_series` — every linked charge gets the tag at sync time and on retroactive apply. + +### Counterparties + +A counterparty is the canonical, cross-provider "other side" of a charge — merchants and non-merchants alike (Venmo, people, employers). Same model as series: a thin entity (surrogate identity, `name`, optional enrichment fields like `website_url`, `logo_url`, `category_id`, `mcc`) whose membership comes from `assign_counterparty` rules. + +| Tool | Scope | What it does | +| --- | --- | --- | +| `list_counterparties` | Read | List counterparties. | +| `get_counterparty` | Read | Fetch one counterparty's `name` and enrichment. Governing rules appear on the admin Counterparties detail page. | +| `assign_counterparty` | Write | One-off link/mint — bind transactions to an existing counterparty (`counterparty_id`) or mint/resolve by `name` + `create_if_missing:true`, with optional enrichment in the same call. For durable patterns, author an `assign_counterparty` rule. | +| `update_counterparty` | Write | Edit a counterparty's name or enrichment fields. | +| `unlink_counterparty_transactions` | Write | Detach transactions from a counterparty. | diff --git a/transactions/rules.mdx b/transactions/rules.mdx index b9d61f0..e29b1db 100644 --- a/transactions/rules.mdx +++ b/transactions/rules.mdx @@ -1,22 +1,22 @@ --- -title: "Rules: automate categorization, tagging, and annotations" -description: "Build condition-based rules that categorize, apply tags, and leave annotations on transactions automatically — at sync time or retroactively — using Breadbox's recursive AND/OR/NOT condition engine with a multi-stage pipeline." +title: "Rules: the substrate that turns provider data into intelligence" +description: "Build condition-based rules that categorize, tag, annotate, flag, link to a series or counterparty, and write free-form metadata on transactions — at sync time or retroactively — using Breadbox's recursive AND/OR/NOT condition engine with a multi-stage pipeline." sidebarTitle: "Rules" --- -Breadbox's rules engine lets you define conditions that run automatically whenever transactions are synced. When a transaction matches a rule's conditions, the rule assigns a category, adds tags, or leaves a comment — without any manual intervention. +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 $50 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. -## What rules do - -A rule is a JSON document that pairs 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. You can also apply rules retroactively to your full transaction history at any time. +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 a tag to the transaction (`add_tag`) -- Remove a tag from the transaction (`remove_tag`) -- Leave an automated comment explaining the categorization (`add_comment`) +- 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](/guides/tracking-subscriptions) (`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 @@ -25,7 +25,7 @@ Rule conditions use a recursive tree of **leaf nodes** and **combinators**. A **leaf node** tests a single field: ```json -{ "field": "merchant_name", "op": "contains", "value": "Starbucks" } +{ "field": "provider_merchant_name", "op": "contains", "value": "Starbucks" } ``` A **combinator** groups multiple conditions with logic: @@ -42,27 +42,42 @@ Combinators nest to any depth up to 10 levels. An empty condition object `{}` ma | Field | Type | Description | |-------|------|-------------| -| `name` | string | Raw transaction description from the institution | -| `merchant_name` | string | Enriched merchant name (may be empty for CSV or un-enriched rows) | +| `provider_name` | string | Raw transaction description from the institution | +| `provider_merchant_name` | string | Provider's enriched merchant name (may be empty for CSV or un-enriched rows) | | `amount` | numeric | Transaction amount — positive = money out, negative = money in | -| `category_primary` | string | Provider's raw primary category (does not change when Breadbox reassigns) | -| `category_detailed` | string | Provider's raw detailed subcategory | +| `provider_category_primary` | string | Provider's raw primary category (does not change when Breadbox reassigns) | +| `provider_category_detailed` | string | Provider's raw detailed subcategory | | `category` | string | Currently assigned Breadbox category slug (updates mid-pipeline as rules run) | | `pending` | boolean | Whether the transaction is pending | -| `provider` | string | `plaid`, `teller`, or `csv` | +| `provider` | string | `plaid`, `teller`, `simplefin`, or `csv` | | `account_id` | string | Account UUID | | `account_name` | string | Account display name | | `user_id` | string | Family member UUID | | `user_name` | string | Family member display name | | `tags` | tags | Current tag slugs on the transaction | +| `series` | string | `short_id` of the recurring series the transaction belongs to (empty when unassigned) | +| `in_series` | boolean | Whether the transaction is linked to any recurring series | +| `counterparty` | string | `short_id` of the counterparty bound to the transaction (empty when unbound) | +| `has_counterparty` | boolean | Whether the transaction is bound to any counterparty | +| `day_of_month` | numeric | Day of the posting date (`1`–`31`) | +| `month` | numeric | Month of the posting date (`1`–`12`) | +| `day_of_week` | numeric | Weekday of the posting date (`0` = Sunday … `6` = Saturday) | +| `day_of_year` | numeric | Ordinal day of the posting date (`1`–`366`) | +| `metadata.` | metadata | One key from the transaction's free-form metadata blob — e.g. `metadata.tax_deductible` reads `metadata["tax_deductible"]`. | -Use `category` (not `category_primary` or `category_detailed`) when you want a condition to react to the category that Breadbox or a prior rule assigned. The `category_primary` and `category_detailed` fields always hold the provider's original values and never change. +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.` 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** (`name`, `merchant_name`, `category_primary`, `category_detailed`, `category`, `provider`, `account_name`, `user_name`): +**String fields** (`provider_name`, `provider_merchant_name`, `provider_category_primary`, `provider_category_detailed`, `category`, `provider`, `account_name`, `user_name`, `series`, `counterparty`): | Operator | Behavior | |----------|----------| @@ -73,25 +88,24 @@ Use `category` (not `category_primary` or `category_detailed`) when you want a c | `matches` | RE2 regex match (case-sensitive by default; use `(?i)` for insensitive) | | `in` | Value is in the provided array (case-insensitive) | -**Numeric fields** (`amount`): +**Numeric fields** (`amount`, date-parts): | Operator | Behavior | |----------|----------| | `eq` | Equal | | `neq` | Not equal | -| `gt` | Greater than | -| `gte` | Greater than or equal | -| `lt` | Less than | -| `lte` | Less than or equal | +| `gt` / `gte` / `lt` / `lte` | Standard comparisons | +| `approx` | `value ± 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). | +| `between` | `min ≤ actual ≤ max` (inclusive). Requires sibling `min` and `max`. | -**Boolean fields** (`pending`): +**Boolean fields** (`pending`, `in_series`, `has_counterparty`): | Operator | Behavior | |----------|----------| | `eq` | Equal to `true` or `false` | | `neq` | Not equal to `true` or `false` | -**Tag fields** (`tags`): +**Tag field** (`tags`): | Operator | Behavior | |----------|----------| @@ -99,9 +113,20 @@ Use `category` (not `category_primary` or `category_detailed`) when you want a c | `not_contains` | Transaction does not have the specified tag slug | | `in` | Transaction has any of the slugs in the provided array | +**Metadata fields** (`metadata.`): + +| Operator | Behavior | +|----------|----------| +| `exists` / `not_exists` | Key presence test; `value` is ignored | +| `eq` / `neq` | Boolean, numeric, or stringified comparison driven by the expected value's type | +| `contains` / `not_contains` / `matches` / `in` | String ops on the stringified stored value | +| `gt` / `gte` / `lt` / `lte` | Numeric 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 `add_tag` and `add_comment`, every matching rule contributes. +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 name | Priority | Purpose | |------------|----------|---------| @@ -114,18 +139,18 @@ Supply `stage` as a string in the request body. You can also supply a raw `prior ### 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. +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.` — a later rule reads what an earlier one wrote. ## Creating a rule -Here is a complete example that categorizes Amazon purchases: +A complete example that categorizes Amazon purchases: ```json { "name": "Amazon purchases", "conditions": { "and": [ - { "field": "name", "op": "contains", "value": "AMAZON" }, + { "field": "provider_name", "op": "contains", "value": "AMAZON" }, { "field": "amount", "op": "gt", "value": 0 } ] }, @@ -147,7 +172,7 @@ curl -X POST \ "name": "Amazon purchases", "conditions": { "and": [ - { "field": "name", "op": "contains", "value": "AMAZON" }, + { "field": "provider_name", "op": "contains", "value": "AMAZON" }, { "field": "amount", "op": "gt", "value": 0 } ] }, @@ -160,7 +185,94 @@ curl -X POST \ http://localhost:8080/api/v1/rules ``` -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. +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` + +```json +{ "type": "set_category", "category_slug": "food_and_drink_groceries" } +``` + +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` + +```json +{ "type": "add_tag", "tag_slug": "needs-review" } +{ "type": "remove_tag", "tag_slug": "needs-review" } +``` + +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` + +```json +{ "type": "set_metadata", "metadata_key": "tax_deductible", "metadata_value": true } +{ "type": "remove_metadata", "metadata_key": "needs_receipt" } +``` + +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.` conditions. + +### `assign_series` + +```json +{ "type": "assign_series", "series_name": "Spotify", "create_if_missing": true } +``` + +Links the matching transaction to a recurring [series](/guides/tracking-subscriptions). 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` + +```json +{ "type": "assign_counterparty", "counterparty_name": "Spotify USA Inc", "create_if_missing": true } +``` + +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` + +```json +{ "type": "flag" } +{ "type": "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` + +```json +{ "type": "add_comment", "content": "Auto-categorized by rule: Dining" } +``` + +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: + +| Actions | Use case | +| --- | --- | +| `set_category` alone | Straightforward reclassification. | +| `set_category` + `add_tag` | Reclassify and annotate (e.g. Uber → `transportation_rideshare` + `recurring`). | +| `assign_series` + `set_category` + `add_tag` | The full subscription pattern — link the charge, categorize it, tag it. | +| `flag` + `add_comment` | Surface a transaction with a reason recorded in the timeline. | +| `add_tag` + `remove_tag` (different slugs) | Transition between tags (add `reviewed`, remove `needs-review`). | +| `set_metadata` | Capture 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 @@ -173,7 +285,7 @@ curl -X POST \ -d '{ "conditions": { "and": [ - { "field": "name", "op": "contains", "value": "AMAZON" }, + { "field": "provider_name", "op": "contains", "value": "AMAZON" }, { "field": "amount", "op": "gt", "value": 0 } ] } @@ -203,36 +315,20 @@ curl -X POST \ -Retroactive apply respects the same pipeline stage ordering as sync. It also respects `category_override` — transactions whose `category_override` is not `'none'` are skipped for `set_category` actions, just as they are during 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 are designed to narrate a specific sync event and are only written during live syncs. +`add_comment` actions do **not** fire during retroactive apply. Comments narrate a specific sync event and are only written during live syncs. -## Respecting manual overrides - -If a transaction has been manually categorized (its `category_override` is `'user'`), a rule's `set_category` action is skipped for that transaction. The rule still runs — `add_tag` and `add_comment` actions fire normally — but the category is not changed. This means you can safely apply rules in bulk without worrying about undoing deliberate manual work. - -## Assign a series - -The `assign_series` action links a matching transaction to a recurring [series](/guides/tracking-subscriptions) — a subscription or other repeating charge. It's the series counterpart to `set_category`: author the rule once and every future matching charge joins the series automatically. Provide **exactly one** of: +## Last-writer-wins, no provenance guard -- `series_short_id` — link to an existing series by its short ID. -- `merchant_key` + `create_if_missing: true` — mint a household series for that merchant the first time a charge matches, then link every future match to it. - -```json -{ "type": "assign_series", "merchant_key": "spotify", "create_if_missing": true } -``` - -A transaction belongs to at most one series; if several rules try to assign one, the highest-priority rule wins. Minting honors a rejected series — a rule can't resurrect a subscription you've dismissed. - - - `assign_series` materializes at sync time and on **single-rule** [retroactive apply](#applying-a-rule-retroactively) (`POST /rules/{id}/apply`) — applying that one rule links matching existing transactions. The bulk *apply-all* path doesn't materialize `assign_series` yet, so apply the rule on its own to back-fill. You can also link a charge by hand or with the `assign_series` MCP tool. - +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. ## Related reading - [Rules API](/api/overview) — REST endpoints for creating, listing, updating, deleting, applying, and previewing rules. - [MCP: rules (read)](/mcp/reference/rules) and [MCP: rules (write)](/mcp/reference/rules-write) — the equivalent tools agents use to create and maintain rules on your behalf. - [Categories](/transactions/categories) — how the two-level hierarchy that rules target is structured. +- [Tracking subscriptions](/guides/tracking-subscriptions) — how `assign_series` builds your recurring catalog. - [Review workflow](/transactions/review-workflow) — how the seeded `needs-review` rule drives the default triage queue.