cashflow
Server Details
Personal finance ledger for AI agents — query spending, track bills, forecast cash flow.
- Status
- Healthy
- Uptime
- 100.0% over the last 24 hours
- Last Tested
- Transport
- Streamable HTTP · MCP 2025-11-25
- URL
- ChatGPT
- –
- Claude
- –
TDQS
Scored across 5 tools
The five tools have distinct high-level roles (read ledger, CRUD reference data, modify transactions, dashboard, paycheck card), but several surfaces overlap: bank connection status is split across accounts, admin bank_connection, and query modes; recurring bills appear in both admin and query; and account summaries can come from accounts or query. The mega-tool design (admin with 16 entity families, query with 21 modes, annotate with many actions) makes misselection likely for an agent that has not memorized the full dispatch rules.
Names mix nouns (accounts, admin, paycheck_plan) with verbs (query, annotate) and do not follow a single verb_noun pattern. They are still short and readable, and each maps to a recognizable functional area, so the inconsistency is not chaotic.
Five tools is a reasonable count for avoiding a sprawling tool list, and each top-level tool covers a distinct broad domain. However, each tool is extremely overloaded with dozens of modes, entities, and actions, so the surface is effectively much larger than five tools suggests.
The surface covers a very broad household-finance domain: transaction queries, annotation, reference CRUD, recurring bills, rules, plans, adjustments, documents, imports, bank connections, private holdings, feedback, and dashboard/paycheck widgets. No obvious lifecycle gaps stand out for the stated scope.
Available Tools
5 toolsaccountsAccounts dashboardARead-onlyIdempotentInspect
Show the user's connected banks and accounts as an interactive dashboard widget. Renders a card per institution with sync status, last-sync timestamp, accounts, and one-click actions (Sync, Reconnect, Disconnect, Connect new), plus a reminder of any bank the user started connecting in the last two weeks and didn't finish, with Try again. Use whenever the user asks to see, manage, sync, reconnect, or disconnect their bank connections. The widget calls back into 'admin' for sync/disconnect and into the web app for reconnect/connect-new (Plaid Link cannot run inside the MCP iframe). Not for a text answer or numbers: connection status in text is admin { entity: "bank_connection", action: "list" }, and balances or per-account activity come from query { account_summary: true }.
Inputs: no named parameters.
adminManage CashflowADestructiveInspect
Manage the household's reference data, plans, documents, and bank connections — the CRUD side of cashflow. 16 entity families, each with its own action set; an unsupported (entity, action) pair is rejected with the list of what that entity does support.
ENTITY MAP: category→global taxonomy, READ-ONLY (list, plus this household's own tags/notes on it) · category_group→its parent groups, list only · party→merchants · tag→transaction tags · account→rename, hide/unhide, tag, note · recurring_series→bills & subscriptions · rule→transaction rules (preview, then create with the returned token) · plan→the annual plan (propose a draft from actuals, then upsert) · adjustment→overlay entries that normalize reported actuals without touching transactions · transfer_link→match two legs of one transfer · document→Paper Trail: file, find, read_text, version, link, tie_out, substantiate, export · import→bank statements (OFX/QFX/QBO + Apple Card, Apple Savings, Rocket Money, Wells Fargo, Monarch, Copilot Money CSVs) · bank_connection→list, sync or disconnect bank connections · feedback→file a bug/feature/ux note · position→private holdings (angel checks, fund commitments, advisor equity) · position_event→what moved one (commitment, call, distribution, fee, mark…)
Reads live in the query tool; per-transaction classification lives in annotate. Use admin for the entities themselves.
A write refuses a field it does not use and names what it accepts — nothing is written, so fix the call rather than assume the field was applied.
CATEGORIES ARE A FIXED SET, AND READ-ONLY: the category taxonomy is the same for every user and cannot be changed. { entity: "category" } supports list, add_tag, remove_tag, set_note, remove_note; { entity: "category_group" } supports list only. create, rename, merge and delete are all rejected on both. An unrecognized spending type belongs under the closest existing category — record the nuance with set_note or add_tag, which are user data about a category rather than a change to it. To move transactions BETWEEN categories use annotate { action: "categorize" } or a rule, not a merge. This also applies to annotate: { action: "categorize", category_name } only ever matches an existing category, it never makes one. Parties, tags, accounts, and rules are unaffected — those are still created, renamed, merged and deleted freely. Activate/deactivate rules via action:"update" with active:true|false. RECURRING SERIES DECISIONS last until the user reverses them — detection never undoes them:
Declare a bill detection missed: { entity: "recurring_series", action: "create", name, frequency, transaction_ids: [id], preview: true }. Supply a cadence even for one selected charge; omit preview to create it and manually link the selected posted charges. The declaration and cadence persist until explicitly ended/rejected; active:true affirms that intent on an existing series. To link later charges (including on another card), use annotate set_recurring with recurring_series_id; it refreshes estimates without silently reviving an ended series.
Set a known next charge: { entity: "recurring_series", action: "update", id, expected_amount: 183.44, next_date: "2026-10-30" }. Amount/date apply to one charge only; later charges retain the estimates. Omit either to keep the current next amount/date. Clears when a new posted charge links or seven days after the expected date. Clear early with clear_expectation:true. Change active separately.
Not recurring (a false detection — a burst of trips, a habit, not a bill): { entity: "recurring_series", action: "reject", id }. Unlinks it and keeps it out of bills, forecast and missed alerts; its charges stay in spending. delete does the same and keeps the row as the marker, so detection cannot re-create it.
Ended (a real bill that stopped): action:"update", active:false. Its past charges stay linked; a new charge after today brings it back.
Restore either: action:"update", active:true.
rename sets the user's name for the series; detection keeps its own and never overwrites the user's. Renaming to the detected name clears it.
When reporting back to the user, translate to plain English.
LIST CONNECTIONS: { entity: "bank_connection", action: "list" } returns each connected bank with status (ok / syncing / needs_reconnect / warning, plus msg), last_sync and ago (when we last asked for new transactions — this moves even when the bank itself has sent nothing, so it is not when the bank last updated), last_new and new_ago (when the bank last sent new or changed transactions; absent if it never has), next_by (around when it is next checked on its own; absent while a connection error stands), no_update_since (present only when the bank stopped sending updates: since then our checks succeed but get nothing newer, and balances may be out of date — a connection that has stopped updating, not a quiet account; status reads warning, msg says what to do), and its accounts. A not_connected row (no id, last_activity instead of last_sync) is a bank whose accounts no connection feeds any more — its numbers stopped updating, and the fix is connecting that bank again, not a reconnect. Answers "is my bank connected?" and "when did it last get anything new?" in text; the accounts tool shows the same as a dashboard.
SYNC: { entity: "bank_connection", action: "sync" } checks every active bank connection now (pass id for one). Rarely needed: new transactions arrive automatically when the bank posts them, and every connection is re-checked at least daily, so sync only when the user reports a specific missing transaction — never on a schedule or before every read. A sync cannot bring in anything the bank has not released yet. Returns added/modified/removed counts per connection with the same status/last_sync/last_new/next_by/no_update_since as list, and a note — a sync that found nothing says when the bank last sent something new, or that the bank has stopped sending updates.
DISCONNECT: { entity: "bank_connection", action: "disconnect", id: "" } revokes the bank connection at Plaid and removes it locally. Returns { ok: true, institution } on success.
FEEDBACK: { entity: "feedback", action: "create", type: "bug"|"feature"|"ux", name: "title", description: "details", tool: "query"|"annotate"|"admin", context: "JSON" }. type and name (the title, max 200 chars) are both REQUIRED; description is capped at 2000. Fire-and-forget — no list/update/delete. Rate limited to 30/day.
IMPORT: { entity: "import", action: "import", file_content: "" } imports a bank export. The format is auto-detected from the content — OFX/QFX/QBO from any US-dollar account, plus Apple Card, Apple Savings, Rocket Money, Monarch, Copilot Money and Wells Fargo checking CSVs; anything else is rejected with detected_format. (csv_content is the same field under its older name.) Pass dry_run: true first to preview without writing — it reports valid/invalid/duplicate rows and sample mapped rows. Re-importing overlapping data is de-duplicated.
Apple Card is SINGLE-account: by default transactions land in an "Apple Card" account created on first import; the response returns account_id. Pass account: "" to target a specific manual account instead (connected/bank-synced accounts are rejected). The preview also reports how much will move from spending to transfers; importing the card moves your Apple Card payments (made from checking) from spending to transfers so they don't double-count against the card's purchases.
Apple Savings is SINGLE-account: by default transactions land in an "Apple Savings" account created on first import — the same account an Apple Savings OFX/QFX/QBO import uses, so importing both exports of a month doesn't double-count. Pass account: "" to target another manual account. Interest is income, Daily Cash deposits are card rewards, and ACH moves to or from a linked bank are transfers; any other activity type imports uncategorized and is named in unmapped_activity_types.
Rocket Money is MULTI-account: a single file carries many accounts (each row names its account/institution/type/number), and accounts are matched or created automatically — the account override does NOT apply. Transfers and ignored rows are taken from the file. Accounts already connected via Plaid are SKIPPED (importing a CSV there would double-count synced data) and reported under skipped_connected_accounts / warnings. The response returns accounts[] (name/status/account_id), accounts_created, imported, and skipped_duplicates.
OFX/QFX/QBO are ONE format in three extensions and are the BEST export to use where a bank offers it: each transaction carries the issuer's own stable id, so re-importing the same file (or the .qfx after the .ofx) updates those rows instead of duplicating them, and a month imported from OFX reconciles with the same month imported from CSV — neither double-counts the other. Accounts resolve from the file's own institution, so account is optional for a single-statement file and rejected for a multi-statement one. Prefer .ofx over .qfx/.qbo: the Intuit variants cut merchant names off at 32 characters, and the import warns when it sees that.
Wells Fargo checking is SINGLE-account and the file omits account identity: pass account:"". A Plaid-connected target also requires allow_connected:true; exact overlapping observations load into Silver and are linked/hidden, while statement-only history remains visible. STATUS=Pending is preserved.
UPLOAD (avoid pasting big files): instead of inlining csv_content, stage the file out-of-band so its bytes never go through context. { entity: "import", action: "request_upload", filename: ".ofx|.csv" } returns a one-time upload_url + a ready-to-run curl (PUT the file from your sandbox: curl -X PUT -T ); the PUT responds with an upload_id. Then import by reference: { entity: "import", action: "import", upload_id: "", dry_run: true } to preview, then without dry_run to commit. Provide exactly one of file_content and upload_id. The upload link is short-lived and self-authenticating (no token needed for the PUT). Prefer this for anything non-trivial — pasting a file inline truncates and burns context. DOWNLOAD ORIGINAL: { entity: "import", action: "get", id: "", with_download: true } returns source metadata plus download {download_url, method, headers, expires_at, filename, content_type, byte_count, sha256}. The native source id is the upload_id returned by staging. It reads the owner's stored CSV/OFX original, never an export reconstructed from transactions. A source whose bytes were not retained reports that limitation. Without with_download, get returns metadata only. The host GETs the bytes without redirects/cookies, verifies actual byte count and SHA-256, then publishes privately to a caller-chosen local path without overwriting. Keep bytes/base64 out of context; on expiry request new instructions for the SAME id. Paper Trail { entity: "document", action: "get", id, with_download: true } supplies the same descriptor and retains its legacy download_url/download_expires_in fields. UPLOAD TRANSFER (imports and documents): filename is the only required file metadata for preparation; no caller-generated IDs, hashes or exact sizes are needed. The host reads the original file, sends raw bytes using the returned method/headers within max_size_bytes, and mechanically compares the receipt's sha256 AND byte_count with its local original before using the native upload_id. Server hashing alone does not verify the local original. On failure or a mismatched receipt, keep the instructions for recovery; do not silently prepare anew or automatically retry.
VESTED INTEREST: what the household owns that has not resolved yet — angel checks, fund commitments, advisor equity — as EVENT LOGS, so cost basis, unfunded and DPI are folds over dated events rather than typed numbers. Read it with query { vested_interest: true }. Field-level rules live on the position, positions, event, events and statement_check params.
Position: { entity:"position", action:"create", position: { name, issuer, holder, … } }, or positions:[…] (up to 100) for a batch. issuer, holder and vehicle are declared, never resolved to a party; one company held through two vehicles is two positions under one issuer. Declare segment rather than guessing it. strike/expiry belong to option / warrant / advisor_equity only; changing the instrument under a stored strike is refused unless both are cleared with null in the same call. update by id (null clears a field) answers with the row's identity plus only the fields THAT CALL set, read back as stored — everything untouched is one get away. get (position + events + folded figures), list (include_resolved:true for exited ones; the first page adds unused: { issuers?, holders?, vehicles? } — registry rows no position uses), delete (removes its events and document links; its issuer, holder and vehicle rows are KEPT and reused by name, and the reply's kept says how many positions each still has).
Terms: record each term WITH its provenance — basis document (+ document_id and page), assumed, or missing — in the source's own wording, never parsed. Then query { vested_interest, terms_basis:["assumed","missing"] } IS the what-only-you-can-answer list; do not keep that in a note. A term you looked for and could not find is basis "missing", not a remove_terms.
Event: { entity:"position_event", action:"create", event: { position_id, kind, amount, effective_date, … } }. amount >= 0. A CAPITAL CALL has three states: the commitment; the NOTICE (kind contribution, status:"pending", effective_date = due date — it shows in the forecast and as past_due, not in paid-in); the WIRE (status settled, supersedes_id = the notice, transaction_id = the bank row), which marks the notice superseded. A MARK is an opinion: mark_source is required and it is never summed — record every statement's NAV as its own mark so a stale one can be seen. Two marks on one as-of date: the one recorded in the LATER CALL wins, so record competing same-day opinions in separate calls, not one batch. A resolved position's last mark is its final word and never goes stale. Every event row carries recorded (when it was entered).
Event list: recorded_since (ISO instant with timezone, at most milliseconds) finds entries recorded since then, inclusively, even when backdated. It returns terse rows in recorded/id descending order within a fixed first-page time window. Keep recorded_since, position_id and pending_only while following cursor; save as_of ONLY from the final page, then use it for the next poll and deduplicate boundary ids. Ordinary lists remain in effective-date order. start/end are refused. This reads current rows by creation time: edits/deletes are not history, pending_only uses current status, and a write timestamped earlier but committed after the traversal can be missed; this is not a commit-ordered change log.
Statement batch: { events:[…], position_id?, statement_check } — refused with identity_mismatch when the statement's own arithmetic does not tie, and with book_mismatch when its contributions or distributions disagree with the book. Fees are never compared (the book's fee kind is out-of-pocket only). The response echoes { scope, scope_source: "declared"|"inferred", book: "ok"|"not_compared" }. statement_check is a gate, not a record — only the events and their verbatim lines are stored.
transaction_id / document_id must be this household's own rows; a foreign id is refused by name. Currency is per position and totals never cross currencies.
DISCOVERY: { entity: "category"|"party"|"tag"|..., action: "list" } returns reference data for the user. All entity types support list. PARTY LIST: Supports search, limit (default 50), and cursor for pagination. Response includes cursor/has_more when more results exist — pass cursor to fetch the next page. The same paging applies to every list in party, rule, tag, recurring_series, position, position_event, adjustment, document (position returns n for the page and matched for the total); category, category_group, account, plan, bank_connection always return the full set.
PARTY ALIASES: {entity:"party", action:"aliases", id?, limit?, cursor?} lists saved spelling/entity keys and both person/machine targets. Without id includes disabled targetless keys. {entity:"party", action:"remove_alias", alias_kind, alias_key} using the EXACT returned key disables that key durably and clears its person target. Its retained machine target is shown but disabled too. Old merges and transaction assignments are unchanged; other matching paths can still pick the same party. A later explicit merge of that spelling/entity can enable the key again.
LIST USAGE: Category and party rows include n, the number of posted, nonignored transactions with that effective category/party in period (default this_month; also last_month, this_year, all). This follows household report visibility: hidden accounts are excluded unless include_hidden:true, while inactive account history remains. Each transaction counts once, including transfers, refunds and zero amounts; adjustments do not add transactions. Categories remain the full taxonomy with n:0 when unused. Party membership and eff/auto counts remain lifetime diagnostics, so a listed party can have n:0. Tag n and src provenance are also lifetime, unaffected by period; adjustment_n separately counts tagged adjustments (all statuses).
RECURRING LIST: next agrees with query { recurring:true }: for active, recently seen bills, it is the next date on or after today; expected carries the original date when next rolls forward. Cancelled bills keep their last expected date. Active bills that stopped charging keep that date too, flagged stale:true; never-seen bills are also stale.
PARTY DUPLICATE REVIEW: query { detail:true, tag:"dup-candidate" } surfaces sample transactions from party-name clusters the diagnostic clusterer (query { clusters:true }) flagged as likely one merchant split across spelling variants.
Resolve via merge: { entity:"party", action:"merge_cluster", merge_into_id:"", member_ids:["", ...] } folds every member into the target in one atomic call — re-points enrichments + plan references, clears the dup-candidate tag on affected transactions, deletes the member party rows. { action:"merge", id, merge_into_id } is the single-pair form (same underlying merge). Both responses include suggest_rule when the merged names share a literal prefix: { description_pattern, party_name, reason } — pass description_pattern straight into the RULE WORKFLOW's preview step when it's present. A plain merge only fixes what already exists; a rule also prevents the same merchant from re-fragmenting on the next import, so treat suggest_rule as a prompt to consider creating one, especially for an established/recurring merchant (a one-off historical cluster not worth a permanent rule is fine as a bare merge).
Dismiss a false positive: { entity: "annotate" (tool), action:"untag", tag_name:"dup-candidate" } on the flagged transaction — writes a suppression tombstone so the same cluster isn't re-flagged.
RULE LIST: Supports search (matches name, description_pattern, or party_pattern), limit (default 50), and cursor for pagination. Response includes cursor/has_more when more results exist. Each rule reports wins (how many transactions it decides now). include_matches:true adds matches (how many transactions its conditions match now — the number rule preview's total gives, counting ignored rows and rows hidden as duplicates too) and last_match (the newest one's date); both are counted fresh from the transactions as they are, including each one's current merchant, so re-applying never changes them and a rule that renames the merchant it keys on can win rows it no longer matches. include_matches reads every transaction: pair it with search or a small limit. RULE WORKFLOW: preview → create/update (with token). A create OR update WITHOUT a token writes nothing and is REJECTED — never silently previewed (so an "add set_party" edit can't quietly no-op). Create auto-applies the rule.
Preview (action:"preview", or preview:true on create/update) returns: { preview:true, total, matched, token, candidate_fields?, manual_matches, txns: [...], conflicts?: [{ id, name, candidate_wins, existing_wins, existing_fields?, shared_fields?, disjoint? }] }. candidate_wins counts the PROPOSED rule's wins; existing_wins counts the NAMED existing rule's wins. Legacy wins/loses mean candidate_wins/existing_wins. Each txn may have conflict (existing rule name) and conflict_winner ("candidate"|"existing"); legacy conflict_outcome ("wins"|"loses") is candidate-relative. One whole rule wins by specificity (longer matched pattern), then oldest rule/id on a tie: different fields do NOT combine. candidate_fields and existing_fields list category, party, is_transfer/is_ignored/is_flagged, or metadata: (tag add/remove both use metadata:tag). disjoint:true means no shared action field, NOT protection from the winning rule. Update fields include retained actions. manual_matches counts all manually edited matches, including off-page; txn.manual:true marks them. Applying skips them unless overwrite_manual:true, which can replace their manual names/categories. total counts matches, not promised writes. txn.raw is the bank's original text (what description_pattern matches); name_in is the source's shorter description, also tested (absent when the same as raw). ign:true marks an ignored transaction; it still counts in total.
Create requires: token from preview + at least one condition + at least one action. name is optional — left off, it defaults to what the rule matches and does ("eBay → Other Income"). Actions: set_category_name, set_party_name, set_is_transfer, set_is_ignored, set_is_flagged, set_tag_name, remove_tag_name (strips a system auto/rule tag from matches; not a user's manual tag; mutually exclusive with set_tag_name). Returns { created, applied }. The token binds the previewed conditions AND actions — change either and the write is rejected, so preview with the same actions you intend to apply.
TRANSFER FLAG: When applied, set_is_transfer:true marks transactions as transfers; set_is_transfer:false clears the flag and overrides automatic transfer classification on future syncs. To recategorize an automatic transfer, preview with set_category_name and set_is_transfer:false together.
PATTERN CHOICE: description_pattern (if_desc) matches the cleaned description AND falls back to the raw bank memo — raw-anchored, survives Plaid merchant churn. party_pattern (if_party) matches ONLY the derived party name, which Plaid can rename on a resync, silently breaking the rule (#1211). For consolidation/renaming, prefer if_desc; a create/update targeting an entity-backed party returns a warning. query { health:true } → stale_party_rules flags a party rule whose merchant kept showing up after the rule's last win without the rule matching it (missed + missed_example show those charges), or that matches no transactions. A merchant that simply went quiet is not flagged.
BEFORE creating, search existing rules (action:"list", search:"") for the same pattern. If one exists, UPDATE it (action:"update", id) instead of creating a parallel rule — duplicate/overlapping rules pile up and the older one silently wins. A create with conditions identical to an existing active rule is refused unless confirm_duplicate:true; a create for a merchant that already has a rule still succeeds but returns a warning.
AUDIT: { entity:"rule", action:"list", audit:true } returns groups (same-merchant rules; exact:true = identical conditions, exact:false = differing) and shadowed rules (their conditions match transactions but they decide none — a more specific rule or a manual edit takes every one). Run it to find redundancy.
Update: { entity: "rule", action: "update", id: "", ...changes }. Same round-trip as create (preview:true, then re-send WITH the token); the token binds the merged conditions (existing + updates) AND the actions. active:true|false needs no token — one call that re-applies or clears the rule's effects.
ACCOUNT VISIBILITY: { entity: "account", action: "hide", id: "" } hides an account from queries and listings. "unhide" reverses it. List with include_hidden:true to see hidden accounts (flagged hidden:true). An account in another currency than the household's is listed with status:"not_counted", a notice to relay, and its own ccy: it is shown, never summed.
TAGS: { entity: "account"|"category", action: "add_tag"|"remove_tag", id, tag } manages tags on accounts and categories. Tag is lowercased and hyphen-joined (e.g. "Emergency Fund" → "emergency-fund"). Canon tags for accounts: operating, bills, emergency-fund, savings-goal, brokerage, retirement. Canon for categories: essential, discretionary. Custom tags are allowed; non-canon adds return is_canon_tag:false. List responses on account/category include tags: string[].
NOTES: { entity: "account"|"category", action: "set_note"|"remove_note", id, note } manages a free-form text note on each account or category. Single note per entity — set_note replaces any existing note (UPSERT). Empty/whitespace notes are rejected. List responses on account/category include note: string when present. Useful for context like "joint account, both partners contribute" or "Software category includes hosting + dev tools".
PLAN: the household's annual financial plan — one declarative document per year (income items, tag-bound events, ONE everyday-base number, guardrails, exclusions). Amounts in the household's currency; category/group/party referenced by id; tags by name. Tags in the document (events, matches, guardrail scopes, exclusions) reference TRANSACTION tags; guardrail scopes and exclusions may instead use { category_tag: "..." } to target CATEGORY tags (the essential/discretionary canon) — { tag } and { category_tag } are different namespaces. Plans are keyed by year — name is rejected (year defaults to the current year). Upsert rejects a guardrail/exclusion scope naming the other namespace (a { tag } that is only a category tag, or vice versa); it warns (not errors) on other tags matching nothing and on events whose bindings overlap.
Propose (draft from actuals): { entity: "plan", action: "propose", year? } → { document, basis, notes } — a draft plan built from the household's own last-12-months actuals (everyday base from trailing expenses, income from recurring deposits, events from annual/semiannual bills, guardrails on the top groups at last year's levels). Read-only: nothing is written. Review + edit the returned document, then pass it to upsert. Year defaults to NEXT year. Use this instead of authoring a plan from a blank page.
Upsert: { entity: "plan", action: "upsert", document: {...} }. First upsert for a year writes the plan as written; upserting over an existing plan REQUIRES reason and records a dated revision (the original is never edited). The server computes the seasonal base shape from the prior year's history (falls back to flat with shape_note when history is thin). Get output is valid upsert input (one exception: a stored scope naming the other tag namespace is rejected until fixed in that revise): server-computed base fields (shape, shape_source, shape_note) are accepted and ignored on write — recomputed every upsert.
Get: { entity: "plan", action: "get", year? } → latest document. revision: 1|"original" for the plan as written; "diff" for original-vs-latest structural diff + reason trail. No plan for that year → { error, hint } pointing at propose.
List: { entity: "plan", action: "list" } → one row per year with revision count.
Resolve an exception: revise the plan (upsert with reason) so the inputs change and the breached exception evaporates on the next plan_status read. Open exception keys are listed in query { plan_status: true } exceptions[].
Delete: { entity: "plan", action: "delete", year } — removes the whole plan year (all revisions); reports the revision count. Requires an explicit year (or id); for mistakes, not for revising.
Status reads live in the query tool: query { plan_status: true }.
ADJUSTMENT: an overlay that normalizes reported actuals WITHOUT editing real transactions — actuals stay the immutable "as-booked" truth; adjustments fold in only when a report asks for the adjusted view. CRUD via { entity: "adjustment", action: "list"|"create"|"update"|"delete", adjustment: {...} } — the payload goes under the "adjustment" key (NOT "document", that's for plans). Amounts in the household's currency; periods as YYYY | YYYY-MM | YYYY-MM-DD; free-text label is "note" (aliases: memo, rationale). TARGET A CATEGORY (category_id OR category_name; aliases category / group / group_name) — this is what makes a reclass/imputation show up in the category breakdown (top_cats) and in grouped by:category; without it only the headline total moves. Three types:
accrual (period-shift): { type: "accrual", direction, amount, source_period, target_period, category_name? } — moves a flow from source_period to target_period (e.g. a January bonus earned the prior year). Nets to zero across a range spanning both.
imputation: { type: "imputation", direction: "outflow", amount, target_period, category_name?, funding_account_id? } — books a real off-feed expense (in category_name, e.g. "Property Tax") plus an offsetting investment withdrawal. funding_account_id is OPTIONAL (validated if given; the withdrawal leg books regardless), so off-feed sources you don't track need no stand-in account.
reclass (alias "exclude"): { type: "reclass", direction, amount, target_period, category_name? } — excludes/removes a one-off from the series (e.g. an estimated-tax check distorting operating spend). Set category_name so it reconciles in the category breakdown.
Preview before writing: pass dry_run:true to create — returns the computed legs + net effect without inserting.
Tags: adjustment:{tags:["trip-slug"]} sets household transaction tags on create/update (full replacement; omit preserves; [] clears). List/create/update return tags. Tags apply to both primary and funding legs; type:"expense" selects the expense in grouped reports, while summary reconciliation retains the investment offset. Tag names share admin tag list/rename/delete; tag n counts bank transactions and adjustment_n counts reporting entries. A YYYY period remains year-only, never inferred from trip dates. Retire the imputation if the real payment is later imported to avoid counting twice.
Soft-retire with update { id, adjustment: { status: "retired" } } to drop it from adjusted reports while keeping the audit trail; only status="applied" entries fold in.
See the result in the query tool: query { view: "adjusted" } (alias "actual" for as_booked) changes reporting totals (and their prior-period comparisons); query { reconcile: true } returns the as-booked↔adjusted bridge (summary + time-series).
Real expense paid outside connected bank feeds: { entity:"adjustment", action:"create", dry_run:true, adjustment:{"type":"imputation","direction":"outflow","amount":1200,"target_period":"2022-08-15","category_name":"Hotels","tags":["summer-trip-2022"],"note":"Lodging paid outside connected bank feeds"} }. Check list for an existing entry first, review the preview, then omit dry_run to save. This adds $1,200 of Hotels spending and an equal investment withdrawal; overall net and original bank transactions stay unchanged. Query the same dates with view:"adjusted", reconcile:true, or by:["category"], type:"expense" for the spending breakdown.
Use imputation only when investment-withdrawal funding matches the payment. Adjusted reports support summary, time series, and grouped reports with exactly one group/category/tag dimension and type other than pnl. Category/group filters select each leg separately; tag/exclude_tag select the entry's tags on both its expense and funding legs. Party, account and other unsupported transaction filters disable the overlay: check view and param_warnings for as_booked fallback. Tags overlap: never sum tag rows. tag_report and detail remain bank-only; tag child breakdowns and matrices keep a reporting-entry remainder. Adjustments create no bank transactions. Coarse YYYY or YYYY-MM periods count in full on any overlap, without proration, and land in the first overlapping trend bucket; use an exact YYYY-MM-DD payment date when known. Retire the imputation if the real payment is later imported to avoid counting it twice.
DOCUMENT (Paper Trail): the household's permanent document store — statements, receipts, invoices, forms, policies and contracts, filed against the transactions and accounts they explain. Params are FLAT (doc_type, title, issuer, ...), NOT nested under a "document" key — that key belongs to plans.
FILING IS TWO CALLS, and the bytes never go through context. { entity: "document", action: "request_upload", filename: "receipt.pdf" } returns upload_url + a ready-to-run curl (PUT the file from your sandbox); the PUT responds with an upload_id. Then { action: "create", upload_id, doc_type, title, issuer, period_start, period_end, tax_year, link_transaction_ids, link_account_ids, link_position_ids, provenance }. provenance records where you got it (a Gmail thread id, a Drive file id, a portal URL) so the trail survives the conversation. There is no inline-content option: never paste a PDF into a tool call.
FILING MANY IS STILL TWO CALLS. { action: "request_upload", files: ["q1.pdf", "q2.pdf", ...] } mints a slot per file at once (up to 25); the response supplies uploads[] {filename, upload_url} and one curl_template (replace <LOCAL_PATH> and <UPLOAD_URL> per slot); PUT each to its own upload_url; then ONE { action: "create", documents: [{ upload_id, title, ... }, ...] }. Top-level document fields are DEFAULTS every entry inherits and may override — { issuer: "Scribble Ventures", tax_year: 2025, documents: [{ upload_id, title: "Q1 report" }, ...] } — except supersedes_id, which is never inherited. The batch answers with n + documents[] (each carrying its index), and one document still answers with document + download_url. Filing is PER DOCUMENT: a bad entry comes back in errors with its index and does not stop the rest, and re-sending is safe (filing the same bytes twice updates that document rather than duplicating it). Naming one upload_id twice in a batch is refused outright. Do NOT merge separate documents into one PDF to save calls — a statement and a report filed as one blob can no longer be superseded or linked apart.
PAPER TRAIL TRANSFER: use the returned binary Content-Type unchanged for PDFs/JPEGs/PNGs; the server recognizes their original bytes and stores the matching MIME. For each batch slot, combine the shared method/headers/limit with that slot's upload_url before transferring; the host routine needs no document-specific header override. Verify each receipt before filing.
LINKAGE IS THE PRODUCT, and it is EXPLICIT. You name the transactions, accounts and Vested Interest positions a document is evidence for; nothing is inferred from the description, the party or the amount. A link to a transaction, account or position outside this household is refused, not skipped. File a K-1 with link_position_ids naming the position(s) it reports — a consolidated K-1 package names every position it covers — and query { tax_documents } marks those K-1s received. To link a document already on file, call create again with upload_id set to its id plus the link_position_ids — links are added, never replaced.
doc_type is DECLARED, not detected: statement | receipt | invoice | tax_form | policy | contract | disclosure | notice | other. There is no classifier — set it from what the user tells you, and say so rather than guessing confidently.
STATEMENT PERIODS ARE READ FOR YOU: filing a doc_type "statement" PDF without period_start/period_end parses the billing cycle out of the document (a closing date + days-in-cycle, a numeric two-date period such as Chase's opening/closing dates, Navy Federal's and Wells Fargo cards' statement period or Citi's billing period, an explicit range, or Wells-Fargo-style beginning/ending balance dates). The response echoes period_source:"extracted" and period_form — CONFIRM the dates with the user rather than assuming them. A period you pass is never overwritten — but if it disagrees with the cycle the document itself declares, the response carries period_extracted + period_note so you can re-file with the right dates (hand-typed periods are the main source of off-by-a-day cycles). A non-PDF or an unknown form just files without one, and tie_out reads the stored period back to decide which ledger rows a statement covers.
FIND: { action: "list", account?, start?, end?, doc_type?, tax_year?, issuer?, search?, search_scope?, text_status?, link_transaction_ids?, link_position_ids?, include_superseded?, limit?, cursor? }. Returns n (this page), total (all matching documents), has_more and cursor when more remain; default 50, max 500. Continue with cursor and the same filters. "search" matches what the filer typed (title, issuer, filename, note, provenance) AND the documents' own text — "which fund reports mention Whatnot" is one call. A body hit carries text_match {page_n, pages, lines}: how many pages contain the term, the first few, and a line of context; read_text with find reads the rest. search_scope:"metadata"|"text" narrows it (default "all"). When some documents' bodies could not be searched the response says so — text_queued (awaiting indexing; body search may miss them, though requeued files can retain old searchable pages) and text_unsearchable (scans and photos with no text layer) — so a miss is not proof of absence. Indexing runs hourly in bounded batches, with no completion estimate. List with text_status:"queued" and the same archive filters, omitting search, to identify waiting documents. text_status also accepts indexed, partial, no_text, not_text, unreadable, no_bytes, corrupt, failed (list/export ONLY). Each row shows filed_age_days: whole elapsed days since filing, NOT queue residence; omitted when filing time is unknown. Dates match by PERIOD OVERLAP (a March statement matches a search for 15 March); a document filed with no period_start is not date-searchable. { action: "get", id } adds a short-lived download_url.
READ WHAT IT SAYS: { action: "read_text", id } returns the document's text — the numbers inside a K-1, a statement's balances — without downloading anything. PDFs are read in reading order; a stored text file comes back verbatim. Optional from_page/to_page (1-based, inclusive; 40-page cap) and max_chars (default 50,000; the response says truncated:true when it bit — narrow the pages rather than raising it). Optional find:"term" returns ONLY the pages containing it (case-insensitive), with match_pages and the matching lines — the way to check one sentence in a 13-page FAQ without reading it all (match_n:0 when it is not there). A scan or a photo is refused BY NAME as no_text/not_text: those need OCR, which only the reconcile CLI can run. A window past the end refuses as out_of_range with the document's page count.
VERSIONS: pass supersedes_id on create for a correction or re-issue. The old edition is marked superseded (hidden from list unless include_superseded) and its links move to the new one — except links whose target no longer exists, which the response lists as dropped_links.
RELINK AFTER FILING: { action: "link", id, link_transaction_ids?, link_account_ids?, link_position_ids? } adds links to a filed document (targets checked against this household); { action: "unlink", id, link_*_ids } removes exactly those links, including ones whose target no longer exists, and refuses a link the document does not hold. To re-point a link, link the new target first, then unlink the old one. query { health: true } lists the links that need it under orphaned_edits.
TIE_OUT: { action: "tie_out", id } ties a filed statement's OWN transaction table out against the ledger, row by row, one report per linked account. The workflow: file the statement (linked to its account via link_account_ids), then tie_out by document id — matched counts the rows with ledger twins, unexplained lists statement rows with NO ledger twin (candidate ledger gaps — the thing worth investigating), ledger_only lists ledger rows the statement doesn't show, explained counts rows whose only twin the ledger deliberately hides (dedup losers). verdict is "clean", "review" for weak/hidden/summed evidence (rollups: several statement rows posted as one; splits: one statement row the feed posted as several), or "discrepancies". This all-source comparison can include reconstructed rows and does not establish independent Plaid coverage. Add match_trace:true (limit default50/max500) for bounded transient row/page references, ledger ids, tiers/date deltas/ambiguity and rollup diagnostics tied to fingerprints, with separate imported-book and Plaid results. No Plaid account/feed observations means unavailable coverage, not proof of missing imported transactions. Traces omit amounts/memos and are not saved; total/truncated name omitted rows. It compares rows, not balances. Read-only. A scanned statement with no text layer is refused by name (no_text) — the reconcile CLI is the OCR-capable door for those.
SUBSTANTIATE: { action: "substantiate", account?, start?, end? } asks which charges have a document filed as evidence — vouching, from the charge out to its support. Returns is_onto (every charge has at least one document), is_one_to_one (nothing carries more than one of the other), is_bijection (both, plus no unattached documents), plus without_document — the charges nothing explains.
EXPORT: { action: "export" } returns a page of the archive MANIFEST — filed documents with their links, hash and byte size, plus n (this page), total (all matches), has_more and cursor when more remain. Default 50, max 500; continue with cursor and the same filters. Always available, and it takes the same filters as list (doc_type, tax_year, issuer, search, search_scope, text_status, account, start/end, link_transaction_ids, link_position_ids, include_superseded, limit, cursor). Bytes are not included and download_urls are NOT emitted by default: pass with_urls:true if you need them, knowing each one is a live bearer token and the whole payload lands in the transcript. For one document use { action: "get", id }; for a real offline archive run the export-documents CLI.
RECONSTRUCT: { action: "reconstruct", id } turns a filed statement's own transaction table into ledger rows — the tool for a period no feed covers (a dead bank connection, history older than Plaid reaches). DRY-RUN BY DEFAULT: it reports what it would insert and writes nothing until dry_run:false. It refuses when the statement's cycle already holds visible rows for that account, because a statement's descriptors do not reliably dedup against an aggregator's — "force" overrides that and WILL insert duplicates by design, so the response carries a batch_id for "undo-import --batch-id" to roll back. Every section must pass the statement's own printed arithmetic before anything is written. Use "section" to pick one account out of a combined statement when the ledger account has no mask.
DELETE: { action: "delete", id } is real — the document and its links go at once. Its stored file is kept (Cashflow never deletes stored files), so filing the same file again undoes the delete.
RELATED: query { tax_documents: true } PREDICTS which tax forms a household should expect from its income signals, and now attaches what is on file — each expected row carries filed when a document filed for that tax year NAMES that form, plus also_filed for the ones no prediction claimed, plus a k1 checklist — the K-1s the partnership interests in Vested Interest imply for the year, each received / missing / not_due. So the query mode answers BOTH "what am I still waiting for" and "what do I have" for tax forms; use this entity to file, version, download, tie out or substantiate any document, tax form or not. Filing with an accurate title and tax_year is what lets the prediction find it.
TRANSFER_LINK: the matched-pair overlay for internal transfers. The matcher auto-pairs the two legs of a move between your own accounts (outflow from one, inflow to another) so they cancel in the Transfers line; this surface reviews and corrects those pairs. { entity: "transfer_link", action: "list", link_status?: "suggested"|"confirmed"|"rejected" } lists links (from/to account, amt, dt, status). { action: "confirm", transfer_link_id } promotes an ambiguous "suggested" pair to confirmed and flags both legs as a transfer. { action: "reject", transfer_link_id } marks a false pair rejected and undoes any flagging — rejects are STICKY (the matcher never re-suggests them). { action: "link", outflow_txn_id, inflow_txn_id } manually pairs two transactions the matcher missed (must be opposite directions, different accounts, equal amounts).
OPERATION CONTRACT (generated from dispatch): every supported entity.action is listed below. Write lines name accepted fields besides entity/action; required values and confirmation rules still apply. Omit fields not listed for that write. category_group.list category.list category.add_tag: id, tag category.remove_tag: id, tag category.set_note: id, note category.remove_note: id party.aliases: id, limit, cursor party.remove_alias: alias_kind, alias_key party.list party.create: name, party_type party.rename: id, new_name party.merge: id, merge_into_id party.merge_cluster: merge_into_id, member_ids party.delete: id account.list account.add_tag: id, tag account.remove_tag: id, tag account.set_note: id, note account.remove_note: id account.rename: id, display_name, new_name account.hide: id account.unhide: id tag.list tag.create: name tag.rename: id, name, new_name tag.delete: id, name recurring_series.create: name, frequency, transaction_ids, preview recurring_series.list recurring_series.update: id, active, expected_amount, next_date, clear_expectation recurring_series.reject: id recurring_series.rename: id, new_name recurring_series.delete: id rule.list rule.preview: description_pattern, is_regex, party_pattern, direction, amount_min, amount_max, account_id, set_category_id, set_category_name, set_party_id, set_party_name, set_is_transfer, set_is_ignored, set_is_flagged, set_tag_name, remove_tag_name, set_metadata_key, set_metadata_value, preview_limit, preview_cursor rule.create: name, active, description_pattern, is_regex, party_pattern, direction, amount_min, amount_max, account_id, set_category_id, set_category_name, set_party_id, set_party_name, set_is_transfer, set_is_ignored, set_is_flagged, set_tag_name, remove_tag_name, set_metadata_key, set_metadata_value, token, confirm_broad, confirm_duplicate, preview rule.update: id, name, active, description_pattern, is_regex, party_pattern, direction, amount_min, amount_max, account_id, set_category_id, set_category_name, set_party_id, set_party_name, set_is_transfer, set_is_ignored, set_is_flagged, set_tag_name, remove_tag_name, set_metadata_key, set_metadata_value, token, confirm_broad, confirm_duplicate, preview rule.delete: id plan.propose plan.upsert: document, year, reason, name plan.get plan.list plan.delete: id, year, name adjustment.list adjustment.create: adjustment, document, dry_run adjustment.update: id, adjustment adjustment.delete: id transfer_link.list transfer_link.confirm: transfer_link_id transfer_link.reject: transfer_link_id transfer_link.link: outflow_txn_id, inflow_txn_id import.import: csv_content, file_content, upload_id, filename, account, allow_connected, account_map, dry_run import.request_upload import.get bank_connection.list bank_connection.sync: id bank_connection.disconnect: id feedback.create: type, name, description, tool, context document.request_upload document.create: documents, upload_id, note, doc_type, title, issuer, period_start, period_end, tax_year, intake, mime_type, supersedes_id, link_transaction_ids, link_account_ids, link_position_ids, provenance document.list document.get document.read_text document.delete: id document.link: id, link_transaction_ids, link_account_ids, link_position_ids document.unlink: id, link_transaction_ids, link_account_ids, link_position_ids document.export document.substantiate document.reconstruct: id, account, section, force, dry_run document.tie_out position.create: position, positions position.update: id, position position.get position.list position.delete: id position_event.create: event, events, position_id, statement_check position_event.update: id, event position_event.delete: id position_event.list COMPACT EXAMPLES (use IDs returned by list/search, never invented IDs):
Rename: {entity:"party",action:"rename",id:"",new_name:"Corner Shop"}; recurring_series rename uses the same fields. Its update accepts id with active, or a one-occurrence expected_amount/next_date override, or clear_expectation.
Merge: {entity:"party",action:"merge",id:"",merge_into_id:""}. The target is kept. A name collision is a refusal to resolve with list/search, not a server failure.
Rule update: {entity:"rule",action:"update",id:"",set_is_flagged:true,preview:true}, then repeat the same edits with the returned token instead of preview. preview_limit/preview_cursor belong only to action:"preview"; omit them on update. Active-only toggles use {id,active} without preview/token. No-match and collision refusals do not mean the service failed.
Inputs: action (required), entity (required), id, end, +109 more.
annotateOrganize transactionsADestructiveIdempotentInspect
Categorize, tag, rename, and organize transactions. Set party, category, transfer status, or ignored flag. Bulk operations: apply rules, detect recurring bills and subscriptions. Raw bank data is never modified, but a change replaces the earlier label, note or flag on that transaction, and revert clears manual overrides.
When reporting back to the user, translate counts and outcomes to plain English ("I categorized 14 transactions"). Don't echo schema field names.
ACTIONS:
Classify: categorize, set_party, tag, untag, annotate (set notes/url). Tags are normalized (lowercased, spaces→hyphens — "Greece Trip" stores as "greece-trip"); tag/untag input is normalized to match. untag is source-aware: removing a system (auto) tag also stops it from coming back on the next automatic refresh (re-tagging lifts the suppression); rule-applied tags are refused — edit or delete the rule instead.
Reserved tags: 'one-time' excludes a txn from recurring detection/forecast (effective next detection run, or immediately via detect_recurring); 'reimbursable'/'reimbursed' drive query { reimbursables: true }. 'refund' is the system's own marker for a returned charge.
Metadata: set_metadata (key+value; one value per key — set replaces), remove_metadata (key, optional value). Custom keys only: "tag"/"notes"/"receipt" are reserved and rejected with a pointer to their dedicated actions. Keys are trimmed + lowercased.
Flag: mark_transfer, mark_ignored, set_recurring
Revert: revert (clear manual overrides, restore import defaults)
Bulk: detect_recurring (creates/updates recurring_series — use preview:true to preview), apply_rules (applies saved rules — use preview:true to preview, rule_ids to limit which rules), infer_categories (fills a placeholder category from the party's own history elsewhere in the ledger; skips manual overrides and any party whose history isn't decisive; preview:true first). detect_recurring (always) and apply_rules with no filter/transaction_ids sweep the whole household: preview:true is a true dry run (no writes, no swept_at) and returns NO token — apply by re-running the same call without preview. NOTE filter.is_uncategorized will NOT find its targets — that means category IS NULL, and these rows carry a placeholder category. Select them with filter.category: ["Other Expense", "Other Income", "Uncategorized"] plus a date range COMPOUND: categorize accepts party_name/party_id to set both in one call. set_party accepts category_name/category_id/group_name to set both in one call.
TARGET: transaction_ids (max 5000) OR filter — one is required for most actions; if both are sent, the filter is ignored and a hint says so. detect_recurring takes no filter (use detect_start). FILTERS: filter.party, filter.category, filter.group, filter.tag, filter.exclude_tag are exact match (case-insensitive), accept string or string[]. filter.country ("GR" / "GRC" / "Greece") and filter.city select by where the merchant is. filter.account matches name or display name (exact, case-insensitive). filter.search is fuzzy text search across display name, party name, description, and check number. Date scope: filter.period (same named periods as query: this_month, last_month, ...) OR filter.start/end (YYYY-MM-DD) — not both. No date scope = ALL TIME. Also: amount_min/max, direction, is_uncategorized, is_recurring, is_transfer, is_ignored, is_pending, is_flagged, has_url, include_hidden. Unknown filter keys are rejected with a validation error (never silently dropped). Date keys at the TOP level (query's flat shape) are also rejected — they belong inside filter. TOKEN (filter-targeted writes only — explicit transaction_ids and filterless sweeps need none): call with preview:true → { matched, token, preview:[sample], will_set? }, review the matched rows and validated category/party/series targets (creates_party says whether a requested party name needs creation), then re-send the SAME action + filter WITH that token to execute (the token param says what makes one stale). Over 20000 matches is rejected: narrow filter.period/filter.account or pass explicit transaction_ids. Preview rows match detail: amt is signed (inflows positive, outflows negative), desc is the displayed name, raw is the original bank memo. RESPONSE: { affected: N, action, matched?, token?, will_set?: { category_id?, party_id?, party_name?, creates_party?, recurring_series_id? }, range?: { start, end } | "all", detail?: { ... }, warnings?: [...], hint? }. range echoes a filter write's effective date scope — check it matches your intent before and after bulk actions. REQUIRED PER ACTION: categorize needs category_id or category_name; set_party needs party_id or party_name; tag/untag need tag_name; annotate needs notes or url (notes:"" clears the note); set_recurring needs recurring_series_id; set_metadata needs key + value; remove_metadata needs key. mark_transfer/mark_ignored/revert need no payload. A missing one is rejected up front, before any write or preview. When affected=0, an error field explains why (e.g. party not found, category not found, missing required field, no matching transactions, missing/stale token) — or, when the requested state already holds (re-applying a tag), a hint says so. When overriding a rule-classified transaction (≤5 explicit IDs, categorize/set_party/mark_transfer/mark_ignored), warnings include rule details and recovery instructions. The write always succeeds; warnings are advisory. NOTE: To explain/debug a transaction's classification, use query { explain: true, transaction_id: "" } instead.
Inputs: action (required), end, key, url, +23 more.
paycheck_planPaycheck planARead-onlyIdempotentInspect
Show the person's next few paychecks as an interactive card: for each check, the bills and transfers due before the next one lands, a share set aside toward bills that come less often, and what's left; plus whether what's due before the next check is covered by checking + savings today. Built from their detected paycheck and recurring bills — nothing to set up. Use it for budgeting by paycheck: what a check has to cover, how much to put aside each check, what's left after bills. The result is also JSON you can read (amounts positive, in the household's currency, named by ccy). If no regular paycheck is detected it says so — then use query { recurring: true } and query { forecast: true }.
Inputs: checks, spread.
queryLook up financesARead-onlyIdempotentInspect
Query the household ledger — spending, transactions, recurring bills, forecasts, runway, money moved between accounts, plan-vs-actuals, tax prep, and data health. One tool, 21 modes, auto-detected from params.
MODE MAP (first match wins, in this order): digest:true→daily flag briefing · health:true→data-quality dashboard · explain:true+transaction_id→why this name/category · plan_status:true→annual plan vs actuals · vested_interest:true→private holdings register · subscriptions:true→subscriptions by kind, competing, paid twice, Apple bill · recurring:true→bills & subscriptions · forecast:true|forecast_months|scenario→projected balance runway · paychecks:true→income streams · account_summary:true→per-account rollup · flows:true→money between own accounts · reimbursables:true→money owed back · tag_report:true→per-tag project ledger · tax_ledger:true→taxes paid/refunded · tax_documents:true→expected tax forms · clusters:true→merchant dedup candidates · detail:true→individual transactions · by+granularity→P&L matrix · granularity alone→trend over time · by alone→aggregate with pct · period alone→income/expenses/net summary
COST: two shapes run an order of magnitude (10-25x) slower than everything else on real households — period:"all", worst combined with search (a substring match defeats the date index and scans lifetime history), and clusters (nothing bounds it, even at its default window). Bound a search with a period where you can. WITHIN a mode the reads already run in parallel, so one mode call is the optimized form — never split a mode into several calls (clusters is the exception: a single serial scan). ACROSS calls, independent queries — two periods, or several modes for a briefing — cost the SLOWEST when issued together in one batch but the SUM when chained, so batch them.
SHAPE: filters go top-level, never nested under a filter key (that is annotate's shape). type:"pnl" nets income−expenses (operating); default type:"cashflow" rows exclude transfers, and its net is the change in cash in every mode (see TYPES).
When reporting results to the user, translate to plain English. Don't echo schema field names like monthly_net, pct, vs_prior, param_warnings — say "money in", "share of total", "change from last month", etc.
FIELD GLOSSARY (terse response fields → plain English): amt=amount, cat=category, grp=group, acct=account, bal=balance, avail=available balance, dt=date, n=count, pct=percent, dir=direction (money in/out), freq=how often, xfer=transfer, ign=ignored, pend=pending, ptype=party type, ctype=category type, pfc=the bank's original category (from Plaid), docs=how many documents are filed against this transaction, sign=which way the amounts point ("inflow+ outflow-" = money in positive; "spend+ refund-" = spending positive, refunds negative; "inflow+ outflow-; expenses spend+ refund-" = every section money in positive except the expenses section, which is spending positive).
ADJUSTMENT TRIP TAGS: admin adjustment create/update accepts tags:["trip-slug"] (omit preserves, [] clears). query {tag:"trip-slug",view:"adjusted"} includes only matching applied entries; category/group/exclude_tag compose per leg. query {by:["tag"],type:"expense",view:"adjusted"} shows booked spending plus tagged adjustments, with as_booked_amt and adjustment_amt on contributing rows; n still counts bank transactions. Each tag is independent: never sum overlapping tag rows. Default/as_booked totals stay unchanged. Use summary with reconcile:true for a whole-selection bridge, including an imputation's investment funding offset. A YYYY entry uses period overlap, never a fabricated payment date; finer windows are not proof of when that payment occurred. Tag-report mode remains bank-only and warns if adjusted is requested. Tag child splits/nests and tag matrices retain reporting-entry residuals.
REPORTING VIEWS: view:"as_booked" reports recorded activity and is the basis for cash/statement comparisons with the same account, date and financial-kind scope. view:"adjusted" applies reporting entries without changing those transactions and can differ from cash that moved in the range; reconcile:true shows the bridge. Financial breakdowns, current/prior comparisons, elapsed pacing, FI and expense/savings target formulas follow the displayed financial view. Bank balances, planned recurring facts and historical income shape retain their recorded basis. Reconcile-only keeps displayed amounts and comparisons as booked. A bridge includes scoped reporting-entry kinds and their funding offsets alongside the selected ledger kinds, so an expense-only bridge can carry an investment funding component. Period sums use exact selected dates, even when calendar column labels are wider. P&L operating net remains income minus expenses; change_in_cash includes investment, financing and transfer activity.
REPORTING ENTRY BREAKDOWNS: Reporting entries can lack a real party, account, tag or their own child category. A grouped row then carries reporting_entries.split and/or reporting_entries.items, each {amt,reason}, for that specific alternative child breakdown. Each remainder is already included in the parent; add it only to its own child projection when checking a complete single-valued breakdown. Matrix sections or expense groups carry reporting_entries:{amounts,reason}. Residuals preserve signed reversals and create zero transaction rows. Ordinary top limits can omit primary rows; overlapping transaction tags remain independent and cannot be summed into a partition.
TRANSACTION DATES: date_basis is supported in detail, summary, grouped and time-series queries. Omitted or source keeps the existing date supplied by each source. posted uses the source's settlement date when available, otherwise its original date; delivered pending rows still have provisional dates. authorized filters and groups strictly by source authorization date, excluding missing dates even for period:'all'. This can be incomplete spending coverage. Detail keeps dt unchanged and adds authorized_dt (null when missing), posted_dt (null while pending), and basis_dt for posted/authorized sorting. date_coverage explains exclusions and fallback. Comparisons, cursors, report drilldowns and exports retain the basis; changing it requires restarting pagination. For cash movement and statement comparisons use posted and inspect its fallback caveat; authorization views do not establish when cash moved. Other modes reject date_basis. Reporting adjustments are disabled for posted/authorized because their dates are separate. No date basis reveals pending transactions a source has not delivered.
MODE DETAIL (priority order is the MODE MAP above):
Digest: { digest: true } → daily "what should I flag" briefing for a scheduled/cron agent: a 30-day low-balance forecast + the attention items (unusual charges, price changes, bank fees, missed bills, late income, capital calls past due or due within 14 days, stale vested-interest marks, vests in the next 30 days and option/warrant expiries within 12 months, broken bank connections), in one bounded read-only call. Optional period/start/end widen the anomaly window (default this month).
Health: { health: true } → data quality dashboard: unnamed accounts, uncategorized transaction stats including pending rows (pending_n counts them, including 0 when all are posted), suspected misclassified income (large inflows not in income category), orphan parties, empty categories, stale recurring series (bills that stopped charging), stale party rules (a rule that stopped matching a merchant that keeps charging, or that matches no transactions), orphaned edits (a person's edit stranded where it no longer shows: on a row hidden behind its duplicate or posted copy, a rule left with no condition, a document link or plan reference to something deleted — each item says how to fix it)
Explain: { explain: true, transaction_id: "" } → shows how a transaction's name and category were decided. Returns: name-cleanup steps, party match method, rule overrides, the bank's original category (Plaid PFC), all matching rules with scores. Use to debug misclassifications or understand rule behavior before editing. Supports search: { explain: true, search: "STARBUCKS", account: "Wells Fargo" }. Search selects the most recent match and returns matched (full count); use transaction_id for an exact selection. When a plan exists for the transaction's year, includes plan: { year, claimed_by, via? } — which plan item claimed this amount and why.
Plan status: { plan_status: true, year?: 2026 } → annual plan vs actuals: income items received/pending/past_due, events with claimed spend + computed status (upcoming/underway/done), everyday-base pacing (whether everyday spending is on track for the year) vs the household's own seasonal shape, guardrails (spending limits), exceptions, full-year forecast. Resolve exceptions by revising the plan (admin { entity: "plan", action: "upsert", reason }) — the plan changes so the breach evaporates on recomputation. No plan for the year → { error, hint } naming admin { entity: "plan", action: "propose", year } to draft one from the household's own actuals. Ignores period/filters — scoped by plan year.
Vested Interest: { vested_interest: true } → the register of private holdings (angel checks, fund commitments, advisor equity): per position paid-in, unfunded, distributed, DPI and net cash, open and past-due call notices, vesting on grants, and the latest mark with its as-of date. Add mark:"none" to enumerate positions without a settled mark, or mark:"stale" for open positions whose latest mark is older than 180 days — the gaps behind include:["marks"] coverage, with the usual filters and paging. To read a whole book in one call use rollup_by (groups, not rows) rather than paging; resolved positions come back only as the resolved_omitted count unless include_resolved. Paging, filters, rollup_by, compact, changed_since and include:["marks"] are described on their params. Write side: admin { entity: "position" | "position_event" }.
Subscriptions: { subscriptions: true } → the subscription review. A subscription is a live recurring charge in Media (any) or Health › Gym — its category decides, so recategorize to move one in or out. Grouped by kind (video, music, ai, cloud_storage, memberships, reading, fitness, games, software, other) with monthly + yearly cost; competing services side by side (overlap); one service paid on two series this cycle (twice); subscriptions that stopped charging (stopped); 2–3 month-apart charges detection hasn't caught yet (looks_new; tag one one-time to dismiss it); the Apple / Google bill and how much of it the list explains (billed_through); a named service filed under another category (check_category). Ignores period and filters.
Recurring: { recurring: true } → recurring bills and subscriptions, forecast, duplicates
Forecast: { forecast: true } → chronological projected events from recurring series with running balance (within a day, money in first). Projects only series on the balance-pool accounts (checking+savings, or accounts tagged
billswhen narrowed); series elsewhere (e.g. credit-card subscriptions) are excluded from events and named in warnings. For the full bills list regardless of account, use recurring mode. Open capital-call notices from Vested Interest (admin { entity: "position_event", status: "pending" }) project as dated outflows too — a past-due notice lands on today; a notice projects against the position's funding_account_id; with none set, a pool narrowed tobillsaccounts cannot place it and names it in the forecast_scope warning, ahead of any recurring series.Paychecks: { paychecks: true } → income-side recurring series with cadence + monthly_avg, plus trailing-90d non-recurring income (variable comp). Filters out inflow series misclassified as transfer/investment/financing via majority-vote categoryType.
Account summary: { account_summary: true } → one row per active account with inflow/outflow/net/bal/n. Defaults to last_90d. Use for envelope / multi-account skills. Pass include_hidden:true to include hidden accounts.
Flows: { flows: true } → money moved between the user's own accounts (from/to/net/gross). Includes rows where isTransfer=true plus investment and financing category types. Rule-demoted transfers (isTransfer=false on a transfer-typed category) are excluded. Resolves counterpart accounts by name matching. Defaults to last_90d.
Reimbursables: { reimbursables: true } → money the user is owed back — outflows tagged 'reimbursable' and not yet 'reimbursed'. Returns { outstanding, n, items, reimbursed_total? }. Tag the expense 'reimbursable' when you pay; add 'reimbursed' once it comes back. Optional period scopes by transaction date (default all time).
Tag report: { tag_report: true } → per-tag project/tax ledger: { sign, tags: [{ tag, total, n, first, last, top_categories }] }. Totals a trip/project across categories. A transaction can carry several tags or none, so tag totals overlap and exclude untagged transactions. Read each tag independently; never sum across tags or expect their totals to reconcile to an overall total. Honors ledger filters; defaults to all time. Pages by absolute total descending, then tag: limit 1–200 (default 25), cursor, has_more. Finish when has_more is false for the complete inventory of tags in the selected payment-date window; zero-net tags remain. coverage:{basis:"payment_date",range:{start,end}|"all",tagged_only:true} states coverage explicitly. This is payment-date coverage, not travel dates: prepaid trip charges outside the window are excluded. Keep the same range/type/filters/household/viewer while paging; limit may change. Totals are live, so restart after concurrent edits. Tags are normalized (lowercase-hyphen) — pass "Greece Trip" or "greece-trip", both resolve.
totalfollows the response'ssign: "inflow+ outflow-" by default (like detail amt); type:"expense" gives "spend+ refund-" (the sign plan_status uses).Tax ledger: { tax_ledger: true } (US households only; elsewhere { not_available, country }) → what moved between you and tax authorities in a period: { payments: [{ cat, amt, n }], paid, refunded?, refunded_n?, net_paid, covers, excludes }. Payments are Federal/State/Local/Property Tax by category; refunds are inflows from tax authorities. net_paid = paid − refunded.
excludesnames what's out of view (paycheck withholding isn't visible in the bank feed) — surface it so a W2 user isn't told they paid $0. Defaults to this_year. Organizes what you paid to hand to a preparer — never computes tax owed or gives tax advice.Tax documents: { tax_documents: true } (US households only; elsewhere { not_available, country }) → which tax forms to expect, from the money we can see: { year, basis, expected: [{ form, issuer, issuer_hint?, from, amount, confidence, issuer_confidence, threshold?, filed? }], also_filed?: [{ id, title }], cannot_see: [...], needs_review?: [{ dt?, desc, amt, n?, note? }] }. Wages→W-2 (by employer), interest→1099-INT & dividends→1099-DIV (by account, ≥$10), tuition→1098-T, childcare→statement, charitable ≥$250→acknowledgment.
confidenceis the FORM ("do you get it?");issuer_confidenceis separate ("do we know who issues it?" — low for payroll-masked employers).cannot_seelists what the cash can't reveal (mortgage 1098, brokerage 1099-B, 1099-R, income we can't see) — surface it so the list is never read as exhaustive.needs_reviewis possible-1099 income to chase, not a clean prediction.filedis Paper Trail documents for that tax year whose title or filename NAMES that form — what the document SAYS it is, never a classifier; absent means we did not find one, NOT that it hasn't arrived. When several rows share a form (interest is per-account, wages per-employer) the issuer must match too, so one filed 1099-INT can't mark three accounts received; a document naming a form that fits no single row goes toalso_filed, alongside filed tax forms nothing predicted. Forms named by description rather than number (childcare statement, charitable acknowledgment) never carryfiled.k1?(when the household keeps Vested Interest positions): { expected, received, missing, not_due?, rows: [{ from, via: "vehicle"|"issuer", holder, position_ids, status: "missing"|"not_due"|"received", filed?: [{ id, title, how: "linked"|"name"|"text", page? }] }], unattributed?: [{ id, title, could_be }], text_unread?: [{ id, title, text_status }], not_applicable?: [{ position, instrument, why }], undated?: [{ position, id }], exit_undated?: [{ position, id, last_event }], record_exit? } — one K-1 per vehicle (or issuer, for a direct interest) per holder, for every partnership interest HELD during the year by its own event dates (resolved positions included through their last event): fund/LP instruments, the fund and syndicate segments, anything held through a vehicle, a fund issuer, or an issuer named LLC/LP. A direct SAFE, note, option or warrant is not applicable; an IRA's K-1 goes to its custodian; a position held in a currency other than the household's is assumed to be a foreign fund that issues it no K-1 (each says why in not_applicable). "missing" = the year is over and no K-1 is on file; "not_due" = the year is still running. A K-1 is received when a document for the tax year is linked to the position (admin document link_position_ids — the way a consolidated package names the funds it covers) and names a K-1 or is a tax_form naming no other form, or when its title names a K-1 and its issuer/title fits exactly ONE row (several → unattributed, listing only the rows still open, never attached to all), or — for rows still open — when a page of a K-1 document's indexed text is a partnership K-1 page (Schedule K-1 with Part I "Information About the Partnership", not an estimate or draft) naming that row's vehicle/issuer as a whole-word run — or, for an "X, a series of Y" name a narrow box B wraps, X up to "a series of" with Y's words elsewhere on the page — and no other partnership (how: "text", page = that page). Text never matches a name with fewer than two significant words, a vehicle held by two holders, or a name inside a longer one the book holds, and a series core two book names share finds neither; cover letters, delayed lists and S-corp/trust/state K-1s are not read — link those.text_unreadlists K-1 documents the text pass could not read in full (queued, partial past the 200-page index cap, or no text layer) while a row could still be marked by text: a missing row may be inside one. K-1s the checklist takes leave also_filed.undated= pass-through positions with no dated event, so the year can't be decided — record the commitment or an opening balance with held_since;exit_undated= resolved, but the last event isn't an exit, so a later K-1 can't be ruled out —record_exitgives the call that records it (a distribution, return of capital or write-off on its date). Pass year for a prior year (catching a K-1 that never arrived). A position's declared k1 (admin position update { k1: "none" | "expected" }) overrides every rule above, both ways — "none" for an LLC taxed as a corporation or a foreign fund the rules miss, "expected" for one they wrongly rule out; null clears it back to inferred. Defaults to the just-completed tax year (last_year Jan–Apr, else this_year); pass year/period to scope. A checklist to hand a preparer or reconcile against email — never computes tax or gives advice.Clusters: { clusters: true } → find groups of transactions likely from the same merchant but not unified under one party. Default lookback 18 months; pass cluster_months:0 for an all-time dedup sweep of historical merchants.
Summary: { period: "this_month" } → income/expenses/net totals. Use period:"all" for all time.
Grouped: { by: ["group"], top: 10 } → aggregated by dimension; any grouping containing transaction tag has overlapping buckets and omits total/pct
Time series: { granularity: "monthly", periods: 6 } → period-over-period trend. Granularity options: daily, weekly, monthly, quarterly, yearly.
periodscaps at 90 (enough for last-90-days daily). Defaultperiodsis 6 for all granularities except daily, which defaults to 30. Daily labels include weekday (e.g. "Fri May 22"). Daily elapsed comparison is suppressed (use vs_prior on the latest bucket instead).Multi-dim: { by: ["group"], granularity: "monthly" } → income vs. expenses by group over time (a month-by-month income statement). top caps every section's rows, folding the rest into "Other" (by:tag just drops them); nest.top caps the items under each group
Detail: { detail: true } → individual transactions with pagination. For a scheduled "what's new since last time" check, add { since: "<as_of from previous run>" } to get only transactions synced since that moment; the response echoes a fresh as_of to store for next time — only once the whole set came back, so follow cursor until has_more is absent before storing it. Add { count: true } to also get matched — the total number of transactions matching the filter across ALL pages — so you can size the result before paging.
SIGN CONVENTIONS:
Detail: amt is signed (inflow +, outflow −)
Summary: income is money in (positive), expenses are spending (positive; refunds reduce them, so a refund-heavy period can read negative), net = income − expenses + investments + financing + transfers
Grouped / time series / multi-dim / tag report / plan_status: see the response's
sign. Grouped and time series follow type ("inflow+ outflow-"; type:"expense" → "spend+ refund-"); type:"pnl" and the multi-dim matrix (which ignores type) read "inflow+ outflow-; expenses spend+ refund-". Financing inflows positive, repayments negative.A
reconcilebridge carries its ownsign("inflow+ outflow-; expenses spend+ refund-"), whatever the mode's.pct = percentage share; its denominator is described by the response shape
RESPONSE SHAPES:
Summary: { range, income, expenses, net, transfers, investments, financing?, top_cats: [{ cat, grp, amt, n, pct }], uncategorized?: { n, amt }, anomalies?: { large_unusual?, price_changes? }, vs_prior?, sources_uses?, fi?, warnings?: [{ kind, msg, party? }], accounts? }
Grouped: { range, sign, results: [{ grp|cat|party|acct|tag, amt, n, pct?, items?, split? }], total?, transfers?, net?, net_vs_prior?, vs_prior? }. For grouping without transaction tag, total covers all selected buckets before top truncation. pct uses the sum of absolute bucket net amounts before top, rounded to one decimal with nonzero shares at least 0.1%; true zero stays zero, and rounded shares need not sum to 100%. Under cashflow, net = total + transfers (the change in cash — the same number as summary's net). For the change in cash from the prior period read net_vs_prior (= summary's vs_prior.net); vs_prior tracks total. by:["category"] includes both cat and grp. Any by containing transaction tag omits total, net, pct and grand vs_prior because tags overlap; per-tag amounts and comparisons remain, and untagged transactions can appear as Untagged. type:"pnl" → { income: [...], expenses: [...] } instead (sign "inflow+ outflow-; expenses spend+ refund-").
Time series: { granularity, type, sign, periods: [{ label, amt, n, partial?, transfers?, net?, net_vs_prior?, vs_prior? }], averages?: { amt, net? }, stats? }. amt sums the rows; under cashflow, net = amt + transfers (the change in cash — the same number as summary's net) and averages.net is its mean over the complete periods. For net's change from the prior period read net_vs_prior (present only when a transfers line makes it differ; absent, vs_prior is the same comparison); vs_prior/stats track amt. Unfinished simple time-series buckets carry partial:true; vs_prior and net_vs_prior are omitted when either compared bucket is unfinished. stats covers only the COMPLETE periods: when the newest period is still in progress it is dropped and excludes_partial:true is set, so stats.min/max may not appear in periods[]. type:"pnl" → periods have { label, income, expenses, net, savings_rate, change_in_cash, n, partial? } (sign "inflow+ outflow-; expenses spend+ refund-"). change_in_cash = income − expenses + investments + financing + transfers (net cash change, in the household's currency — summary's net; it and savings_rate count every kind whatever include shows, and include only adds the transfers/investments/financing fields and their rows to n); savings_rate = change_in_cash ÷ sources in PERCENT (the cash bucket — same definition as the summary sources_uses block; bounded to ±100, defined in decumulation).
Multi-dim: { sign, periods: [...labels], income: { total: [...], items }, expenses: { total: [...], groups: [{ name, total, items? }] }, transfers: { total: [...], items }, investments: { total: [...], items }, financing?: { total: [...], items }, net: [...], vs_prior_elapsed? }
Detail: { n, matched?, page_total, txns: [{ id, dt, amt, desc, raw, acct, cat?, grp?, party?, channel?, loc?, xfer?, ign?, flag?, pend?, tags?, docs?, anomaly? }], cursor?, has_more?, as_of? }. page_total is the signed sum of this page's returned txns (money in positive, money out negative), not an all-pages total. as_of appears with since, and only on the page that completes the set. matched (number of filter matches across all pages) appears only with { count: true }. docs is how many documents are filed against that row — absent means none; get them with admin { entity:"document", action:"list", link_transaction_ids:[id] }. channel is "online"/"in store"/"other". loc has city/region/lat/lng/addr when available.
Subscriptions: { sign, total: { monthly, yearly, n }, kinds: [{ kind, monthly, yearly, n, items: [{ id, name, service?, amt, freq, monthly, yearly, acct?, last, next?, via?, price?: { was, now, since } }] }], overlap?: [{ kind, monthly, yearly, items }], bundled?: [{ service, includes }], twice?: [[{ id, name, amt, acct?, last }]], stopped?: [{ id, name, amt, freq, last, acct? }], looks_new?: [{ name, kind, n, amt, first, last, acct? }], billed_through?: { apple?|google?: { charges_90d, total_90d, in_list, not_in_list, recent: [{ id, dt, amt, acct? }] } }, check_category?: [{ id, name, service, cat }] }. total counts confirmed subscriptions only — looks_new and stopped are outside it. id is the recurring series id for admin { entity: "recurring_series" } (rename, update active:false, reject). via:"apple"|"google" = billed through Apple or Google, so it is cancelled there, not on the service's site. billed_through..not_in_list is billed money no listed subscription explains — the bank can't say which apps; an agent with email access can match Apple receipts ("Your receipt from Apple." from no_reply@email.apple.com) to recent by the same total on the same day: a line reading "Renews " is a subscription, anything else a one-off purchase. Pending charges are not included. overlap is a question for the person, never a recommendation to cancel.
Recurring: { series: [{ id, name, renamed?, party, ptype, freq, amt, stddev?, monthly, varies?, dir, ctype, active?, acct, last, next?, expected?, stale? }] (renamed = name is the one the user gave it — show it instead of party; varies = amount swings too much to promise a per-paycheck share; its absence means no claim, not verified-stable), totals: { monthly_income, monthly_expenses, monthly_transfers?, monthly_investments?, monthly_financing?, monthly_net, annual_net }, monthly_sources_uses?: { sources: [{ label, amt, pct }], uses: [{ label, amt, pct }], total, change_in_cash, ratios: { operating_coverage?, net_investing_rate, savings_rate } }, duplicates?, forecast?, missed?, missed_income? }. duplicates = [{ items, reason }]: one service or bill paid on two or more series that all charged this cycle (a named service across cards or plans; otherwise one merchant, one name, about one amount) — money out only; a series that stopped charging is a switch, not a duplicate, and two different apps billed through the App Store are two apps. missed = late BILLS only: money going out to someone else (expenses, loan payments, transfers that pay a card or loan) — moves between the household's own accounts and investment contributions never count. missed_income = late income of at least $25 (scaled to the household's currency), omitted when none — interest cents and round-ups never appear. Note: monthly_net includes transfers/investments/financing (full cash picture). monthly_sources_uses reframes the recurring totals as a balanced statement (change_in_cash == monthly_net) — a recurring portfolio withdrawal shows as a source ("From investments"), useful in decumulation. Its ratios match summary's sources_uses (same units) minus consumption (operating_coverage = recurring income ÷ bills as a ×; net_investing_rate & savings_rate in %). For "bills vs income" compute monthly_income - monthly_expenses. totals/missed/forecast always cover ACTIVE series regardless of is_active (which shapes the series list only); active, recently seen rows show the next date on or after today, matching admin recurring_series list; expected carries the original date when next rolls forward. Inactive and stale rows keep their last expected date as next; stale:true flags active bills that stopped charging or were never observed. type:income/expense narrows the list AND the math.
Forecast: { days, starting_bal, ending_bal, p10_ending_bal?, p90_ending_bal?, p10_shortfall_dt?, lowest_bal, lowest_dt, monthly_net, var_net?, var_in?, var_out?, bills_narrowing_active?, events: [{ dt, name, amt, bal, dir, freq }], warnings? }. ending_bal/lowest_bal/lowest_dt and each event's bal fold in a projected daily variable-spend drift (non-recurring everyday flow on the balance pool, beyond recurring bills). lowest_bal/lowest_dt and the Shortfall warnings judge END-OF-DAY balances: a same-day paycheck that covers a bill is no shortfall. The drift is conservative on both sides: var_out = typical recent monthly spending (recent-6-month average), var_in = reliable monthly income (median of the last 12 months, so one-off bonuses don't inflate it); var_net = var_in − var_out per month (negative = net everyday spend; omitted when flat or under ~30 days of history). p10_ending_bal/p90_ending_bal bracket a likely range for the ending balance given how much the user's everyday in/out swings month to month (roughly an 80% range — about a 1-in-10 chance of ending below p10 or above p90); p10_shortfall_dt is when the balance would dip below zero in that pessimistic (p10) case, omitted if it stays positive or history is too short to estimate the swing. monthly_net is recurring-only and spans all matching series (parity with recurring totals); events cover only the balance pool.
Paychecks: { paychecks: [{ name, party, ptype, acct, cadence, per_paycheck, monthly_avg, stddev?, last, next }], totals: { monthly_avg, n_streams }, irregular_income_90d }. cadence is one of weekly/biweekly/semimonthly/monthly/quarterly/semiannual/annual. irregular_income_90d sums non-recurring inflows that classify as income — non-zero suggests variable comp or side income outside the regular paycheck.
Account summary: { range, accounts: [{ acct, type, inflow, outflow, net, bal, avail?, limit?, n, stale?, stale_since? }], warnings?: [{ kind, msg }] }. One row per active account. Zero-activity accounts still appear so you see the full picture.
stale: truemeansbalis frozen as ofstale_sincerather than current: the bank connection behind that account is broken or gone (stale_since= the last successful sync, or for an account no connection feeds any more, its last activity), or it still works but the bank has stopped sending updates (stale_since= when it last did; nothing to fix yet, reconnecting is the thing to try if it lasts) — say so rather than quoting the balance as live. Healthy rows omit both keys.Flows: { range, n, flows: [{ from, to, net, n, from_acct_id?, to_acct_id?, gross_out?, gross_in? }] }
Clusters: { n, scanned, clusters: [{ name, variants: [{ name, n }], n, sample }], truncated? }. Default top 25; pass top param to adjust. n is total cluster count; truncated shows how many were omitted.
Explain: { txn: { id, dt, amt, dir, raw, name_in?, acct }, name: { display, src, stages?, party?, rule? }, class: { cat, grp, type, src, pfc?, rule? }, rules?: [{ name, score, winner?, actions }], plaid?: { merchant, counterparties?, pfc? }, src }. txn.raw is the bank's original text (what a rule's description_pattern matches) and txn.acct the account name — both as detail prints them. name_in is the source's own shorter description (Plaid's cleaned name), which description_pattern also tests and name cleanup starts from; absent means it is the same as raw.
Health: { unnamed_accounts?: [{ id, name, mask }], uncategorized?: { n, amt, pending_n, top_parties: [{ name, n, amt }] }, suspected_misclassified_income?: [{ id, dt, amt, desc, cat? }], orphan_parties?: { n }, stale_recurring?: [{ name, last?, freq }], orphaned_edits?: { n, inert_rules?, hidden_edits?, hidden_metadata?, document_links?, plan_refs?: { n, items: [{ …, fix }] } } }. unnamed_accounts lists unrenamed blank, number-like, known-mask or generic bank-product names; readable bank-provided names need no rename. Unused global categories are not health issues. All fields omitted when clean.
Digest: { as_of, range, forecast: { days, ending_bal, lowest_bal, lowest_dt, var_net?, warnings?, shortfall_kind?, assumes? }, attention: { unusual, price_changes, bank_fees, missed_bills, missed_income, broken_connections, plan_exceptions?, capital_calls?: [{ position, id, amt, ccy, due, past_due? }], stale_marks?: [{ position, as_of, age_days, source }], newer_recorded_marks?, vests_soon?: [{ position, dt, qty }], expiries?: [{ position, expiry, days, expired?, exercise_cost? }], uncategorized? } }. Stalled connections in attention.broken_connections include no_update_since, msg and url; last_sync is the last successful request, not the bank refresh. A checked section with nothing in it is [] (checked, none); plan_exceptions appears only with a plan for the year, and the Vested Interest lists only with an open position. With a shortfall, forecast.assumes says what the projection counted: { through, start_bal, in: [{ dt, name, amt, freq }], out: [...], more_in?/more_out?: { n, amt }, drift?, late_excl?: { n, amt } } — the largest projected events each side up to the dip (amt signed in+ out-), start_bal + every event's amt + drift = lowest_bal when the conservative line dips (drift is absent when only the P10 band dips, through = p10_shortfall_dt). Late bills are never projected; late_excl counts them.
Vested Interest: { n, positions: [{ id, name, issuer, issuer_kind, holder, holder_kind, beneficial_owner?, instrument, segment?, ticker?, k1?, terms?: [{ term, value?, basis, doc?, page?, note? }], ccy, status, funding_account_id?, committed, paid_in, unfunded, distributed, cost_basis, dpi?, net_cash, held_days?, vested?, unvested?, vested_through?, schedule_through?, exercised?, next_vest?: { dt, qty }, exercise_cost?, pending?: [{ id, amt, due, past_due? }], converted?: { dt, from?, to?, qty? }, instrument_stale?, mark?: { amt, as_of, source, basis?, age_days, stale?, recorded?, newer_recorded?: { amt, as_of, recorded, source } } }], totals?: { : { n, committed, paid_in, unfunded, distributed, cost_basis, net_cash, pending_calls } }, past_due?: [{ position, id, amt, ccy, due }], stale_marks?: [{ position, as_of, age_days, source }], newer_recorded_marks?: [{ position, amt, as_of, recorded, source }], vests_soon?: [{ position, dt, qty }], expiries?: [{ position, expiry, days, expired?, exercise_cost? }], caveat }. All amounts in each position's own currency. unfunded = committed − contributions: fees and exercises are paid-in but never draw a commitment. vests_soon = open positions whose NEXT tranche vests within 30 days (a later tranche is still on the row's next_vest); expiries = within 365 days, or past. converted = the latest conversion (qty = shares received); instrument_stale = the position still declares the instrument it converted from — update its instrument. unvested is OMITTED rather than 0 when no future tranche is recorded — absent means unknown, not fully vested; vested_through = last tranche on or before today; schedule_through (only when a future tranche is recorded) = last tranche on record, not a promise the schedule is complete. stale = a mark older than 180 days (never on a resolved position); newer_recorded names a mark recorded LATER whose as-of sits behind this one.
Plan status: { plan: { year, rev, created, revised_at? }, sign, income?: [{ name, expected, actual?, due_by?, status?, unmatched? }], events?: [{ name, tag?, est, actual, n?, last?, status, window?, over_pct? }], base: { annual, ytd_actual, ytd_expected, pace_pct?, shape_source, shape_note? }, guardrails?: [{ scope, label?, min?, max?, actual, state, no_match? (nothing matched yet this year — normal early on or off-season; a scope matching nothing at all raises a guardrail_empty exception) }], exceptions?: [{ key, kind, msg }], forecast: { projected, actuals_ytd, events_remaining, base_remaining, by_month } }. All amounts in the household's currency. sign is "spend+ refund-; income in+": event, base and guardrail amounts (and the exceptions quoting them) are spend-positive — outflows add, refunds subtract — so an event's actual reads opposite to its tag's default tag_report total. No plan for the year → { error, hint }.
ZERO-RESULT LOOKUPS: Initial search/amount lookups in detail, grouped and simple time series can return excluded_transfers:{n,hint}, a positive transaction row count of matching transfers outside the primary results. Retry the same filters as the hint directs: default cashflow uses is_transfer:true; P&L and income/expense/financing require type:"all" plus is_transfer:true. Grouped P&L requires that retry even with include:["transfers"]. An explicit exclusion stays honored: replace is_transfer:false with true to inspect those rows. This diagnostic does not change counts, totals or paging and is absent on continuation pages and when no matching omitted transfers are found.
PAGINATION: Pass the returned cursor value as cursor in the next call to page forward. Cursors are bound to the sort that issued them — keep the same sort across pages (changing sort mid-pagination is a validation error; start over without the cursor instead).
TYPES:
income: income only (excludes investments + financing)
expense: expenses only (excludes investments + financing)
financing: cash advances + EWA-repaid-from-bank only (DTC borrowing flows: Cleo, Brigit, Dave, etc.)
pnl: income+expenses side-by-side (excludes transfers/investments/financing). Its net is the OPERATING net (income − expenses). Grouped/time-series split into income[] and expenses[] — expenses spend-positive, the rest money-in positive (see
sign). Cash-basis income statement — actuals only; for plan-vs-actuals use plan_status mode.cashflow (default): includes investments + financing, excludes transfers from rows and row totals (unless is_transfer:true). Its
netis ONE number in summary, grouped, time series and the matrix: the change in cash = income − expenses + investments + financing + the transfers line, which is non-zero when a transfer's other leg isn't in view (an unconnected or hidden account, or across the period boundary). Groupedtotal/ time-seriesamtare the row sums without that line.
ADJUSTED REPORTS: view:"as_booked" (default) reports original bank activity. view:"adjusted" includes reporting entries managed by admin { entity:"adjustment" }, including imputation for real expenses paid outside connected bank feeds. reconcile:true adds original versus adjusted totals and entry effects to summary/time-series output. Use imputation only when investment-withdrawal funding matches the payment. Adjusted reports support summary, time series, and grouped reports with exactly one group/category/tag dimension and type other than pnl. Category/group filters select each leg separately; tag/exclude_tag select the entry's tags on both its expense and funding legs. Party, account and other unsupported transaction filters disable the overlay: check view and param_warnings for as_booked fallback. Tags overlap: never sum tag rows. tag_report and detail remain bank-only; tag child breakdowns and matrices keep a reporting-entry remainder. Adjustments create no bank transactions. Coarse YYYY or YYYY-MM periods count in full on any overlap, without proration, and land in the first overlapping trend bucket; use an exact YYYY-MM-DD payment date when known. Retire the imputation if the real payment is later imported to avoid counting it twice.
FILTERS: category, group, party, account, tag, exclude_tag, country, city, search, amount_min/max, direction, is_recurring, is_uncategorized, is_transfer, is_ignored, is_pending, is_flagged, has_url, include_hidden. Scope note: "hidden" applies to ACCOUNTS (include_hidden brings hidden accounts back in); "ignored" applies to individual TRANSACTIONS (is_ignored finds them). exclude_tag drops rows carrying any of the named tags (none-of-any); ordinary labels and untagged rows remain, and [] excludes nothing. Transaction tags are normalized (lowercased, spaces→hyphens — "Greece Trip" → "greece-trip"), so filter input is normalized to match. Tagging a txn 'one-time' also excludes it from recurring detection / forecast.
FILTER COMBINATIONS: filters AND together. Useful combos: { account, is_recurring: false, period, by: ["category"] } isolates non-recurring drafts on one account (good for reconciling against per-account net flow). { account, period } gives that account's income/expenses/net. { account, tag, period } scopes a tag to one account.
SORT (detail mode): sort="amount"|"-amount" (default "-date"). Amounts are signed — for largest expenses use sort="amount" (most negative first); for largest inflows use sort="-amount" (most positive first). Detail limit is 1–200 (default 25); grouped top is 1–50 (default 50), and limit does not cap grouped results.
HIDDEN ACCOUNTS: Transactions from hidden accounts are excluded by default. Pass include_hidden:true to include them. Manage visibility via admin { entity: "account", action: "hide"|"unhide", id: "" }.
SEARCH DEFAULT: When search is provided with no period/start/end, defaults to all time.
COMPARE: prior_period, prior_year — works in summary and grouped modes with a bounded period (not 'all'). The change in net from the prior period is summary's vs_prior.net and grouped's net_vs_prior (the same number). prior_period_elapsed (defined on the compare param) also works in multi-dim time series (not at daily granularity), where it surfaces as vs_prior_elapsed. Check param_warnings.
INCLUDE: stats, warnings, accounts, transfers, investments, financing, fi (extra output sections). transfers/investments/financing add those fields to pnl time series periods. fi adds FIRE primitives (annual_spend, fi_number_25x, fi_lean_20x, fi_chubby_30x, fi_fat_50x, years_to_fi, extrapolation_basis, extrapolation_reliability, decumulation?) to summary mode. targets adds the 80/20 framework defaults: income_shape, buffer { months, target, callable?, current_months, shortfall?|funded } — target = months × expenses + callable, where callable is Vested Interest's unfunded USD commitments not yet noticed (committed − contributions; money owed on demand, so a reserve rather than a forecast event) — and savings_rate { current_pct, default_pct?, gap_pct? }.
ANOMALIES (default-on in summary mode): { large_unusual: [{ desc, amt, dt, party?, vs_avg, reason }], price_changes: [{ name, expected, actual, pct_change, reason }], bank_fees: [{ desc, amt, dt, pfc, kind, reason }], by_account?: [{ acct, total, n }] }. Pre-computed at import time. large_unusual flags per-party transactions exceeding historical median (MAD-based, log-space). price_changes flags recurring series whose recent median deviates from expected amount. bank_fees surfaces NSF / overdraft / late fees (from Plaid PFC). by_account rolls up large_unusual by account so a spending skill can quote "Bill Pay had $X of irregular drafts" without re-summing.
ANOMALY TAGS: each fast-detector anomaly also writes a transaction_metadata tag for cheap filtering — nsf, overdraft, late-fee, price-increase / price-decrease (sign-aware). Use query { detail: true, tag: "nsf" } to drill into bank fees without filtering on anomalyType directly.
SOURCES_USES (summary mode, always-on when there's activity): a Sources & Uses of cash statement that works in BOTH FIRE accumulation and retirement/decumulation — each flow lands on a side by DIRECTION, so investments are a use when contributing, a source when withdrawing. Shape: sources[] and uses[] (each { label, sub?, amt, pct } where pct is % of the equal side total), total, change_in_cash (signed; + added to cash, − drawn down; == net exactly), and ratios: operating_coverage (income÷expenses as a MULTIPLE/×, e.g. 7.4× or 0.24× — not a percent; >1 accumulation, <1 retirement; omitted when no spend), net_investing_rate (−investments÷sources, in PERCENT; its SIGN is the regime flag — + accumulating, − decumulating), savings_rate (change_in_cash÷sources, in PERCENT — the CASH bucket: cash set aside (+) / drawn down (−) after consuming and investing; Sethi's "Savings" bucket, distinct from and non-overlapping with net_investing_rate, so FIRE's total savings rate = savings_rate + net_investing_rate; always present), consumption { essential, discretionary, untagged, reporting_entries? } (each { amt, pct } as % of total sources; reporting_entries also explains why its signed expense amount lacks an attributable category). The three classified buckets plus that reporting-entry remainder equal displayed consumption. The whole block is one sources-denominated frame: consumption + investing + savings partition total cash inflow.
LOCATION: country / city are the MERCHANT's location as the bank reports it, so an online merchant abroad counts as abroad. A trip: { detail: true, start, end, country: "GR" } finds every charge made there in the window, whatever its category.
GROUPING: Category/group buckets partition the same selected transactions (including Uncategorized) and add up before top truncation, unless combined with transaction tag. Transaction tags overlap: one purchase can appear under several labels, so never add the tag rows together. Grouped tag results can include Untagged; tag_report excludes untagged transactions. Matrix headline totals are computed independently of overlapping tag rows. Canonical category tags are different: essential/discretionary/untagged are mutually exclusive. by:["category_tag"] aggregates by canonical category tag (essential / discretionary / untagged). Works in by:, nest.by:, and split_by:. Most useful with type=expense — non-expense categories carry no canon tag and roll into 'untagged' (a param_warning fires on type=income or type=financing). Response key is c_tag. For "P&L split essential / discretionary": by:["category_tag"], type:"expense". For "each spending group split by spending-class": by:["group"], split_by:"category_tag", type:"expense". For per-account or per-owner rollups, use the existing by:["account"] with an account:[...] filter (account_tag grouping is intentionally not exposed — accounts are low-cardinality enough that the model handles them directly).
WARNINGS: { kind, msg, party?, url? } objects. Silent bank-refresh stalls over seven days produce connection_warning with the missing-transactions/balances caveat and a connection-specific reconnect URL, even when cached sync calls succeed. unusual_txn includes the resolved party when known, independently of the transaction display label in msg. Summary mode: always-on connection/coverage warnings (sync_incomplete, connection_error, connection_warning) + selected-transaction flagged/anomaly warnings (bank_fee, price_increase); with include:["warnings"] also selected unusual_txn and uncategorized. Current household missed_bill and missed_income alerts appear only for an unfiltered selection whose range includes today; low_balance additionally requires include:["warnings"]. Dimension/amount/transaction filters suppress these three household alerts, including unknown or empty selections. Date bounds and include_hidden/include_ignored do not narrow that alert scope; is_pending:false and is_ignored:false retain the default summary scope. For a separate current household check, request { period:"this_month", include:["warnings"] } without narrowing filters. Absence of these alerts on a filtered or past report says nothing about current bills or balances. Connection/coverage and param_warnings remain visible. Account-summary mode: the connection warnings only (sync_incomplete, connection_error, connection_warning), matching summary word for word.
PRECEDENCE: start/end beats period, and params that belong to a lower-priority mode than the one detected are ignored. Both are reported in param_warnings.
NEST: { by: "party", top: 5 } — sub-group within each grouped result
SPLIT_BY: any by: dimension — cross-tab each grouped result by a second dimension
RECURRING OPTIONS: forecast_days (0-365, recurring default 30, forecast default 90), is_active (default true). Also accepted by paychecks: is_active, include_hidden.
DISCOVERY: To list available categories, groups, parties, or tags, use admin { entity: "category"|"party"|..., action: "list" }. DASHBOARD: https://cashflow.tech has two views agents can link to:
/spending?start=YYYY-MM-DD&end=YYYY-MM-DD (income statement). Also accepts view=month|quarter|year|all_time for named periods.
/transactions?start=YYYY-MM-DD&end=YYYY-MM-DD&search=X&group=X&account=X (transaction list). Also accepts view param. All filter params optional.
Inputs: by, end, tag, top, +82 more.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
Related MCP Connectors
Personal finance for AI agents — onboard, import statements, categorize & budget over MCP.
The budget app your AI agent can use. Check budgets and log spending, with no bank logins.
Expense ledger for AI assistants: record receipts line by line, ask what you spent, export anytime.
Personal finance ledger: log expenses, track bills and cards, import statements.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to operate a self-hosted double-entry personal finance ledger: answering spending, net-worth and cash-flow forecast questions, searching transactions, and managing schedules, rules and imports through audited, reversible tools. Read and write access is scoped by token, with privacy controls over what financial details reach the agent.AGPL 3.0
- AlicenseBqualityCmaintenanceEnables AI agents to manage personal finances with deterministic, grounded tools for cash-flow projection, drift detection, reconciliation, and Todoist integration. All figures trace back to a local SQLite database.55MIT
- AlicenseNot gradedqualityBmaintenanceEnables AI assistants to read, write, and audit personal finance data over a single SQLite file, covering transactions, receipts (including receipt photos as image blocks), budgets, categories, and line-item price tracking. Every write is an idempotent, soft-deleted, audited upsert so agents can safely transcribe and organize spending.26 npmMIT
- AlicenseNot gradedqualityCmaintenanceEnables AI assistants to manage personal finances through plain-language commands, including expense logging, account and deposit tracking, budget monitoring, and spending reports, with OAuth-secured access to a shared ledger.MIT
Glama MCP Gateway
Add one secure layer between your agents and this server.