{
 "auth": "Authorization: Bearer <personal access token> -- mint one at /settings (one year, read-only or read-write, revocable there); a browser session cookie from /signin also works",
 "endpoints": {
  "GET /api/v1/accounts": {
   "params": {},
   "summary": "the sub-accounts the feed has delivered lines for; pending is counted from the BOOKS (lines the feed's own judgement still holds as pending), so a line that has since posted is not in it",
   "tool": "list_accounts"
  },
  "GET /api/v1/auditor/view": {
   "params": {},
   "summary": "the accountant's own read of the books under a live grant: months (calendar-month totals off the one relation the dashboard reads), pocket_flagged (the owner add-back list) and receipts (the provider's verbatim record), composed under one identity and STAMPING its own use so the customer's list can show when the grant was last read. THE GRANT ID COMES OFF THE TOKEN'S LABEL, never off a query string -- a token that could name its own grant could name somebody else's -- so this takes no params and answers 403 to any credential that is not an auditor grant, including the bearer PAT the MCP face carries. An EXPIRED or REVOKED grant is a 403 naming why and never a 404: 'no such grant' and 'your grant died' are different sentences to the person holding the link",
   "tool": "get_auditor_view"
  },
  "GET /api/v1/categories": {
   "params": {},
   "summary": "the tenant's own categories as the books use them, each with its booked total_cents and line count, most money first; 'uncategorised' is the queue (there is no house chart of accounts here: a label exists once a line carries it)",
   "tool": "list_categories"
  },
  "GET /api/v1/exceptions": {
   "params": {},
   "summary": "lines no live rule and no live op has explained",
   "tool": "list_exceptions"
  },
  "GET /api/v1/me": {
   "params": {},
   "summary": "the books' summary: lines, booked, archived, pending (listed, not yet posted, in no total), total_cents, by_category, `auth` (which credential let you in), and the visible_from floor with vault_rows/visible_rows beside it -- every number here is over what this face SERVES, never over the whole vault",
   "tool": "get_me"
  },
  "GET /api/v1/messages": {
   "params": {
    "limit": {
     "description": "how many messages back, newest first, default 50; the ledger ceilings it at 200 in its own body, so a larger ask is clamped rather than honoured",
     "type": "integer"
    }
   },
   "summary": "this tenant's own thread with the business, newest first: `direction` (`in` what they texted, `out` what was texted back), `channel`, `status`, `body`, `provider_sid` (the carrier's own id for an `out` row, empty when there is not one yet) and `created_at`. DRAFTS ARE NOT HERE and cannot be: a reply is invisible until a human releases it, so this face can never show a customer words nobody chose to send. `count` is the number of rows IN THIS DOCUMENT and not the lifetime total, so a truncated read is distinguishable from a short thread by comparing it against the `limit` asked for",
   "tool": "get_messages"
  },
  "GET /api/v1/meter": {
   "params": {
    "months": {
     "description": "how many months back, newest first, default 36, capped at 120",
     "type": "integer"
    }
   },
   "summary": "the cost meter, one row per calendar month, newest first: `calls` (tail calls, abstentions included -- an unsure answer still crossed the wire and still cost money), `millicents`, `unpriced_calls` (calls whose cost was absent or unparseable, EXCLUDED from millicents and reported rather than dropped), `lines_resolved_local` against `lines_sent_to_api` (the ratio a plan is priced on), and the cap: `cap_tier`, `cap_millicents_month`, `cap_source` and the computed `mode`. `mode` is null when this vault knows no cap, `interactive` under it and `batched` at or over it -- null is NOT 'under the cap', because an unknown cap reported as healthy is the one lie this meter exists to refuse",
   "tool": "get_meter"
  },
  "GET /api/v1/reports/pnl-rolling": {
   "params": {
    "as_of": {
     "description": "ISO date the two windows end on, in America/Chicago; default today",
     "type": "string"
    },
    "tenant": {
     "description": "must be the credential's own tenant; omit it and the credential decides",
     "type": "string"
    }
   },
   "summary": "the Daily Rolling P&L as STRUCTURE, not prose: two windows (7d, 30d), each with the same six sections (revenue, cogs, gross_margin, biz_expenses, net_income, owners_draw) carrying `current` and `prior`, their category lines, and every booked transaction that contributed to each line. gross_margin and net_income are DERIVED (`derived: true`) and re-carry their components' lines with the sign each component enters with, so the twelve section `current` numbers are what a coefficient compares -- do not also sum the derived sections' lines. `lineage_basis` says whether the paper-verified lineage was readable (it is `unreadable` from this door: v_c_ledger does not carry reconciled_state, which changes no section number); `lists_source` names which category lists classified the rows",
   "tool": "get_pnl_rolling"
  },
  "GET /api/v1/rules": {
   "params": {},
   "summary": "the live rulebook, retired revisions included, in the order it DECIDES: the bottom row wins when two rules match a line (priority descending -- the matcher's walk, reversed)",
   "tool": "list_rules"
  },
  "GET /api/v1/sync-history": {
   "params": {
    "limit": {
     "description": "runs to return, capped at 200",
     "type": "integer"
    }
   },
   "summary": "bank-sync runs, newest first",
   "tool": "list_sync_history"
  },
  "GET /api/v1/taxonomy": {
   "params": {},
   "summary": "the tenant's category TAXONOMY as the New category dialog defines it -- name, cat_key, header_key, header_name and the header's sort_order -- live revisions only (a retired category is one the books have stopped deriving, and offering it back is offering a word the taxonomy has retracted), ordered by header then name so it renders the same way twice. This is what create_category and create_categories write into, and it answers whether or not any line carries the label yet; list_categories is the BOOKS' aggregate and answers only labels lines actually carry, with their totals. 404 when this deployment's tap does not request the categories read",
   "tool": "list_category_taxonomy"
  },
  "GET /api/v1/transactions": {
   "params": {
    "cat": {
     "description": "exact category label; 'uncategorised' for the queue",
     "type": "string"
    },
    "from": {
     "description": "ISO date, inclusive lower bound on posted_on",
     "type": "string"
    },
    "page": {
     "description": "1-based",
     "type": "integer"
    },
    "per_page": {
     "description": "rows per page, capped at 100",
     "type": "integer"
    },
    "q": {
     "description": "substring of the description",
     "type": "string"
    },
    "to": {
     "description": "ISO date, inclusive upper bound",
     "type": "string"
    }
   },
   "summary": "rows of C, paged; total_cents is over the WHOLE matched set, never the page; each row carries in_books, exclude_reason and state (in books | pending | superseded | retracted | archived) -- a pending line is listed and in no total",
   "tool": "list_transactions"
  },
  "GET /api/v1/transactions/{side}/{src_id}": {
   "params": {
    "side": {
     "description": "A = the feed's line, B = its archived twin",
     "enum": [
      "A",
      "B"
     ],
     "in": "path",
     "type": "string"
    },
    "src_id": {
     "description": "the line's id on that side",
     "in": "path",
     "type": "integer"
    }
   },
   "summary": "one line, its REQ-8 lineage in words, and for an A line the provider's verbatim record (raw)",
   "tool": "get_transaction"
  },
  "GET /api/v1/transactions/{side}/{src_id}/why": {
   "params": {
    "side": {
     "description": "A = the feed's line, B = its archived twin",
     "enum": [
      "A",
      "B"
     ],
     "in": "path",
     "type": "string"
    },
    "src_id": {
     "description": "the line's id on that side",
     "in": "path",
     "type": "integer"
    }
   },
   "summary": "why this line says what it says, in one templated sentence, with the citations it was built from -- which rule (and its revision), which model and how sure, which human and when, the duh band, and the pocket. `citations` is the evidence and `sentence` is only its rendering: a caller that wants to disagree should read the citations. `engine` names what decided the line; `defect` is non-empty when these books hold NO citation for it, which is a REPORTED gap and never a generated reason. 404 when this tenant holds no such line; 404 with a named hint when this deployment's tap does not request the read",
   "tool": "explain_transaction"
  },
  "GET /api/v1/usage": {
   "params": {
    "days": {
     "description": "trailing window in days, default 30, capped at 365",
     "type": "integer"
    }
   },
   "summary": "the two meters a plan would bill on (inventory E4): bank connections in use against the cap this surface enforces, and transaction volume over a trailing window -- the LARGER of money in and money out, never their sum and never the net, which is the basis the meter measured on 2026-09-01 uses",
   "tool": "get_usage"
  },
  "GET /api/v1/visibility": {
   "params": {},
   "summary": "the tenant's floor and what it subtracts: visible_from (stored at signup, never moves), tier, window_hours (the free tier's rolling window in hours -- 48 -- or null), effective_from (whichever floor subtracts more), oldest_visible_row, vault_rows (everything C holds) and visible_rows (what this face serves). The vault keeps the whole initial pull and nothing is deleted to shrink a view; an empty visible_from with a null window_hours means this tenant has no floor and sees all of it",
   "tool": "get_visibility"
  },
  "POST /api/v1/adjudicate": {
   "params": {
    "a_id": {
     "description": "the exception's A-side id",
     "required": true,
     "type": "integer"
    },
    "category": {
     "required": true,
     "type": "string"
    },
    "match_kind": {
     "enum": [
      "description_exact",
      "description_regex",
      "amount_sign"
     ],
     "required": true,
     "type": "string"
    },
    "match_value": {
     "required": true,
     "type": "string"
    },
    "priority": {
     "description": "lower decides first; default 100",
     "type": "integer"
    },
    "rule_key": {
     "description": "your name for the rule; a new revision if it exists",
     "required": true,
     "type": "string"
    }
   },
   "summary": "resolve ONE exception and mint a versioned rule from it (REQ-8): the line gets its category and every future match is decided by the rule",
   "tool": "adjudicate"
  },
  "POST /api/v1/auditor/grants": {
   "params": {
    "viewer_channel": {
     "description": "the viewer's phone in E.164 (+15551234567) -- it is half the code digest's salt, so a number spelled one way here and another at confirm makes a RIGHT code fail as a wrong one",
     "required": true,
     "type": "string"
    },
    "viewer_name": {
     "description": "who is being let in, as the customer's own audit list will show them forever",
     "required": true,
     "type": "string"
    }
   },
   "summary": "offer these books to a named person: files a grant request and TEXTS A SIX-DIGIT CODE TO THE CUSTOMER'S OWN ENROLLED PHONE (never to the viewer), returning only `request_id` -- the code is confirmed back at POST /api/v1/auditor/grants/{request_id}/confirm, which is what actually mints the grant. SESSION ONLY: a grant is offered by the customer, signed in, so a bearer token (and therefore this MCP tool) is refused 403 -- an auditor that could mint its own grant would renew itself forever. 503 when this deployment holds no outbound SMS credential, checked BEFORE the row is filed",
   "tool": "offer_auditor_grant"
  },
  "POST /api/v1/auditor/grants/{grant_id}/revoke": {
   "params": {
    "grant_id": {
     "description": "the grant to revoke, as GET /api/v1/auditor/grants lists it",
     "in": "path",
     "type": "integer"
    }
   },
   "summary": "take a grant back: the row is stamped revoked_at and every later read through it is refused BY NAME forever (a revoked grant is never resurrected -- a new look needs a new grant). It is a POST and not a DELETE on purpose: this server has do_GET and do_POST and nothing else, so a DELETE would have put the customer's most safety-critical control on the only path with none of _api_write's seven walls behind it. SESSION ONLY: only the customer takes a grant back",
   "tool": "revoke_auditor_grant"
  },
  "POST /api/v1/auditor/grants/{request_id}/confirm": {
   "params": {
    "code": {
     "description": "the six digits texted to the customer",
     "required": true,
     "type": "string"
    },
    "request_id": {
     "description": "the id POST /api/v1/auditor/grants returned",
     "in": "path",
     "type": "integer"
    },
    "viewer_name": {
     "description": "the same name the request was filed under",
     "required": true,
     "type": "string"
    }
   },
   "summary": "spend the texted code and mint the grant: 200 carries `grant` (the live row, with grant_id) and `token`, an auditor-scope credential good for 034's 24 hours and no longer, which reads GET /api/v1/auditor/view and nothing else. 404 names a request id that does not exist; a 403 names the vault's own reason (wrong code, too many attempts, expired request) rather than borrowing the 404. SESSION ONLY: the code went to the customer's phone and only the customer spends it",
   "tool": "confirm_auditor_grant"
  },
  "POST /api/v1/categorize": {
   "params": {
    "category": {
     "required": true,
     "type": "string"
    },
    "side": {
     "enum": [
      "A",
      "B"
     ],
     "required": true,
     "type": "string"
    },
    "src_id": {
     "description": "the line's id on that side",
     "required": true,
     "type": "integer"
    }
   },
   "summary": "set one line's category -- an APPENDED judgement in the op ledger, never an edit; 'uncategorized' unsets; the archive side (B) is refused by the ledger",
   "tool": "categorize"
  },
  "POST /api/v1/create-rule": {
   "params": {
    "category": {
     "required": true,
     "type": "string"
    },
    "match_kind": {
     "enum": [
      "description_exact",
      "description_regex",
      "amount_sign"
     ],
     "required": true,
     "type": "string"
    },
    "match_value": {
     "required": true,
     "type": "string"
    },
    "priority": {
     "description": "lower decides first; default 100",
     "type": "integer"
    },
    "rule_key": {
     "description": "your name for the rule; a new revision if it exists",
     "required": true,
     "type": "string"
    }
   },
   "summary": "file a rule with NO transaction to hang it on (fintable's Create rule; G1's ledger.create_rule): it decides every matching line nobody has already decided, and every future match -- born_from_a_id NULL, which adjudicate can never produce; an APPENDED revision when the key already exists, never an edit (ruling #135)",
   "tool": "create_rule"
  },
  "POST /api/v1/create-rules": {
   "params": {
    "rules": {
     "description": "objects with rule_key, match_kind, match_value, category and optional priority, as POST /api/v1/rules takes them, at most 100",
     "items": {
      "type": "object"
     },
     "required": true,
     "type": "array"
    }
   },
   "summary": "file SEVERAL rules in one call (fintable's create_bulk_rules): every row is validated BEFORE anything is written, then appended in order -- the response carries `created` and stops at the first ledger refusal, which it names",
   "tool": "create_rules"
  },
  "POST /api/v1/export": {
   "params": {
    "destination_id": {
     "description": "which connected destination to re-sync; the newest live one by default",
     "type": "integer"
    }
   },
   "summary": "push the books to their destination (fintable's Force Re-Sync / resync_spreadsheets): when a spreadsheet destination is connected this APPENDS a resync op against it and returns the op id; otherwise it renders the CSV bundle ledgerExport ships and returns its receipt -- the row counts and the four URLs the same books are downloaded from (transactions.csv, accounts.csv, and the QuickBooks pair: quickbooks-bank.csv for QuickBooks Online's Upload from file, quickbooks.iif for QuickBooks Desktop, which is the one that carries the categories), plus `destinations`: `known` when the destination list was read and found empty, `unreadable` when it could not be read at all -- the CSV bundle is real either way, but only `known` means nothing was connected. Booked lines only; pending is never exported",
   "tool": "export_books"
  },
  "POST /api/v1/move-rule-to-end": {
   "params": {
    "rule_key": {
     "description": "the rule's key as list_rules shows it",
     "required": true,
     "type": "string"
    }
   },
   "summary": "make ONE rule the decider for every line it matches (fintable's 'move to end'): an APPENDED revision whose priority is below every other live rule's, never an edit; a rule already deciding appends nothing; touches no line already decided -- the next run-rules is where it shows",
   "tool": "move_rule_to_end"
  },
  "POST /api/v1/new-categories": {
   "params": {
    "categories": {
     "description": "objects with name and header, as POST /api/v1/categories takes them, at most 100",
     "items": {
      "type": "object"
     },
     "required": true,
     "type": "array"
    }
   },
   "summary": "add SEVERAL categories in one call (fintable's create_bulk_categories): every row is validated BEFORE anything is written, then appended in order -- the response carries `created` (the rows that landed, with their ids) and stops at the first ledger refusal, which it names",
   "tool": "create_categories"
  },
  "POST /api/v1/new-category": {
   "params": {
    "header": {
     "description": "the header key it groups under",
     "enum": [
      "cogs",
      "expense",
      "income",
      "draw",
      "transfer"
     ],
     "required": true,
     "type": "string"
    },
    "name": {
     "description": "the category as the books will spell it",
     "required": true,
     "type": "string"
    }
   },
   "summary": "add ONE category to the tenant's taxonomy under an existing header (fintable's New category dialog): an appended definer call, the ledger's own refusal relayed verbatim when the name already lives under another header; 404 when this deployment's tap does not request the verb",
   "tool": "create_category"
  },
  "POST /api/v1/reports/pnl-parity": {
   "params": {
    "as_of": {
     "description": "ISO date the windows end on; default the oracle report's own `as_of`, else today in America/Chicago",
     "type": "string"
    },
    "braatz": {
     "description": "optional, and an ASSERTION rather than an input: this surface recomputes its own side and 409s on a disagreement",
     "type": "object"
    },
    "oracle": {
     "description": "the ORACLE's STRUCTURED report -- what GET /api/v1/reports/pnl-rolling emits in shape, computed by the n8n workflow. A rendered card or the n8n node output is 400: the comparator needs section -> category -> transaction",
     "required": true,
     "type": "object"
    }
   },
   "summary": "grade the DIY n8n oracle's Daily Rolling P&L against braatzledger's own and file ONE deterministic verdict for the day: the difference coefficient over the twelve section numbers, the per-section deltas, and the localization of every delta down to the transactions that explain it (RECATEGORIZED / AMOUNT_DIFF / WINDOW_EDGE / MISSING_ON_BRAATZ / MISSING_ON_ORACLE). POST `oracle` alone and this surface computes ITS OWN side from its own books -- our numbers are not something a caller gets to supply. `braatz` may be sent anyway, and is then CONFIRMED rather than used: if its twelve section numbers disagree with what these books say, the request is 409 and nothing is filed. A second POST for the same day UPSERTS and returns a higher `revision`",
   "tool": "record_pnl_parity"
  },
  "POST /api/v1/retire-rule": {
   "params": {
    "rule_key": {
     "description": "the rule's key as list_rules shows it",
     "required": true,
     "type": "string"
    }
   },
   "summary": "switch ONE rule off (fintable's 'Delete this rule'): an APPENDED revision with retired=true, never a delete -- the transactions it already decided KEEP their categories, it decides no further lines, and the retired revision stays in list_rules so \"we turned it off\" never looks like \"there was never a rule\"",
   "tool": "retire_rule"
  },
  "POST /api/v1/run-rules": {
   "params": {},
   "summary": "run every live rule over the uncategorised lines; returns how many it decided (a second run decides 0 -- idempotent by construction)",
   "tool": "run_rules"
  },
  "POST /api/v1/sync-now": {
   "params": {},
   "summary": "ask for a bank sync now: 202 with state recorded|already-pending (one request per tenant at a time), 409 when nothing is connected; it is a REQUEST the watcher consumes -- list_sync_history is what says it ran",
   "tool": "sync_now"
  },
  "POST /api/v1/transactions/{side}/{src_id}/label": {
   "params": {
    "category": {
     "description": "the category the books SHOULD have given this line",
     "required": true,
     "type": "string"
    },
    "reason": {
     "description": "why, in the tenant's own words -- stored on the label row as provenance",
     "type": "string"
    },
    "side": {
     "description": "A = the feed's line; B is 409",
     "enum": [
      "A",
      "B"
     ],
     "in": "path",
     "type": "string"
    },
    "src_id": {
     "description": "the line's id on that side",
     "in": "path",
     "type": "integer"
    }
   },
   "summary": "\"that's wrong\" -- correct ONE line and have the correction round-trip to a labelled training row in the same breath. ONE ledger.labels row AND the set_category op are written as ONE database fact, so a label without a moved line cannot exist; the OLD category is derived from the ops ledger rather than taken from this caller, because asking the thing being corrected what it had wrong is how a training row gets a fictional \"before\". Returns the r_ops seq that MOVED the line. 409 on side B: an archived duplicate arrival carries no category to correct -- correct its original instead",
   "tool": "label_transaction"
  }
 },
 "in_build_now": [
  "public data API",
  "OAuth app registry",
  "disconnect a bank -- one-way at the provider, so it stays the Accounts room's two-click form and is not an API verb"
 ],
 "mcp": "v3/surface/pwa/mcp_server.py generates its tools from `endpoints` at startup; there is no second list",
 "name": "braatzledger API",
 "version": "v1",
 "writes": "POST with Content-Type: application/json; a read-only token is refused (403); the same capability functions the rooms call, the actor recorded as human:<tenant> (session) or token:<tenant>:<kid>"
}