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: "<uuid>" } → 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 `bills` when 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 to `bills` accounts 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. `total` follows the response's `sign`: "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. `excludes` names 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. `confidence` is the FORM ("do you get it?"); `issuer_confidence` is separate ("do we know who issues it?" — low for payroll-masked employers). `cannot_see` lists 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_review` is possible-1099 income to chase, not a clean prediction. `filed` is 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 to `also_filed`, alongside filed tax forms nothing predicted. Forms named by description rather than number (childcare statement, charitable acknowledgment) never carry `filed`. `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_unread` lists 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_exit` gives 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. `periods` caps at 90 (enough for last-90-days daily). Default `periods` is 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 `reconcile` bridge carries its own `sign` ("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.<channel>.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 <date>" 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: true` means `bal` is frozen as of `stale_since` rather 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?: { <ccy>: { 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 `net` is 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). Grouped `total` / time-series `amt` are 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: "<account-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.