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: "<plaid-item-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: "<raw statement file>" } 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: "<id or name>" 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: "<id or name>" 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:"<id or exact name>". 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: "<name>.ofx|.csv" } returns a one-time upload_url + a ready-to-run curl (PUT the file from your sandbox: curl -X PUT -T <path> <url>); the PUT responds with an upload_id. Then import by reference: { entity: "import", action: "import", upload_id: "<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: "<source 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:"<target-id>", member_ids:["<id>", ...] } 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? }] }. name is optional on preview (and ignored for matching). 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:<key> (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:"<merchant>") 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: "<rule-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: "<account-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: name, 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:"<party-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:"<source-party-id>",merge_into_id:"<existing-target-party-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:"<rule-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.