---
title: Coffer (in development)
---

# Coffer (in development)

<!-- Phase 3 (SimpleFIN bank sync) shipped 2026-07-30 — see "Phase 3 — Bank sync" below. -->

**Status: in-development — default-hidden.** Gated via the `coffer` unreleased-feature
entry (`cofferEnabled` toggle in Settings → Lab, or launch with `AMC_SHOW_COFFER=1`).
Ships dark until flipped to `shipped` in `src/shared/unreleased-features.ts`.

## What it is

Coffer is the first-party **personal-finance tracker** — a Mint.com-style feature built
fully local (no cloud backend, no bank credentials): link nothing, import everything.
It adds a "Coffer" sidebar virtual project (sentinel `__coffer__`, nested in the
Productivity group) whose panel renders a native full-screen finance UI
(`panelOwnsLayout`, arij-style — no vendored bundle, AMC design system throughout).

The product and architecture spec is the research library at
[docs/research/mint/](../research/mint/INDEX.md) — Mint's anatomy rebuilt on the
successors' architecture (Actual Budget's local-first rules engine, Maybe Finance's
account taxonomy, Firefly III's budget semantics).

### Feature surface (Phase 1 local core + Phase 2 intelligence + Phase 3 connectivity + Phase 4 polish/v2)

Fourteen sections in the panel's internal nav: **Overview · Transactions · Budgets ·
Recurring · Goals · Trends · Insights · Accounts · Investments · Rules · Alerts ·
Bank sync · Import · Assistant** (the Import view also carries Export and the demo
seeder; Insights and Investments are the two sections Phase 4.2/4.3 added, and
Assistant is the read-only AI money assistant Phase 4.6 added — see below).

- **Accounts** — typed (checking/savings, credit card, investment, loan, property,
  vehicle, other), on/off-budget, with balances and a daily balance-snapshot series.
- **Transactions** — a ledger with search/filters, split transactions, renameable
  payees, notes, a duplicate flag, and bulk edit; integer minor-unit amounts
  throughout; `imported_id` dedup on every imported row. Dedup is backed at the
  DB layer by a partial UNIQUE index on `(account_id, imported_id)` over live
  rows (`is_deleted = 0`), so even a cross-process re-import race (two
  connections importing the same row) is a no-op counted as `deduped`, never a
  double count.
- **Categorization** — a fixed two-level default category tree + custom subcategories,
  auto-categorization via a staged rules engine (pre/default/post) that **learns from
  corrections**: recategorizing a merchant offers a rule for future (and optionally
  past) transactions. Uncategorized rows land in a review queue.
- **Budgets** — per category per month with Every Month / Every Few Months / Once
  frequencies, an optional per-budget **rollover** (carries surplus AND deficit), the
  "Everything Else" catch-all row, and an **auto-starter budget** proposed from
  imported spending history.
- **Transactions ledger extras** — a right edit sidebar (not a modal): category with
  the "remember this merchant" rule checkbox (+ apply-to-past), payee rename
  (renames the merchant everywhere), notes, duplicate flag, guarded delete, and a
  split editor with exact integer-cent sum validation. Manual/cash entry via the
  "+ Add" form (unsigned amount + Income flip). Every budget's frequency is one of
  `monthly` / `every-n-months` / `once` (displayed as Every Month / Every Few
  Months / Once), with the auto-starter proposing ~85% of trailing average spend
  and add/edit/close plus credit limits and manual balance updates on the typed
  account roster feeding the daily snapshot series behind net worth.
- **Rules & Categories** — every rule in plain language, pause/guarded-delete;
  user subcategories under the fixed default groups, hideable.
- **Import** — Mint-format CSV, generic bank CSV with an in-view column mapper
  (signed amount or debit/credit split), and OFX/QFX (FITID → dedup key). No
  screen scraping, ever; bank sync (SimpleFIN) is a later phase.
- **Export** — the data-longevity escape hatch: one-click Mint-format CSV of every
  transaction (re-importable into Coffer itself — the machine-migration path) and
  a versioned full JSON backup (accounts, custom categories, payees, rules,
  month-keyed budgets, balance snapshots, embedded CSV).
- **AMC backups carry the ledger** — all twenty-one durable `coffer_*` tables
  (the fourteen Phase 1–3 tables — ten Phase 1 tables plus
  `coffer_recurring_series`, `coffer_goals`, `coffer_alert_rules`,
  `coffer_alert_events` — plus the seven Phase 4 tables: `coffer_bucket_budgets`,
  `coffer_securities`, `coffer_trades`, `coffer_holdings`, `coffer_insights`,
  `coffer_attachments`, and `coffer_security_prices`) are classified `merge` in
  the portable-backup coverage map (a self-contained uuid graph) and listed in
  the DSAR/data-transfer table set, so Backup Mirror / Portable Backup restore
  Coffer data like any other user data. The two exceptions are `coffer_meta`
  and `coffer_security_prices`, both deliberately `exclude-ephemeral`:
  `coffer_meta` holds only per-install maintenance watermarks (the
  intelligence-pass throttle cursor, digest cursors, and the quotes-refresh
  watermark), and `coffer_security_prices` is a re-fetchable Yahoo Finance quote
  cache — both are meaningless on another machine and rebuild themselves after
  a restore.
- **Overview dashboard** — summary cards: net worth (with a 90-day sparkline),
  cash flow, spending, budget bars, coming-up bills, goal progress, recent
  transactions, and the uncategorized review-queue nudge.

#### Phase 2 — intelligence

- **Recurring & bills** — subscriptions and bills **detected automatically** from
  history (same payee + similar amount + regular cadence; weekly through yearly),
  each series projecting its next due date and re-projecting as charges match.
  Price increases are flagged on the row and raised as alerts. "Not recurring" is
  a tombstone the detector never resurrects; manual bills cover what the ledger
  can't see. Per-series reminder lead-time override.
- **Transfer auto-matching** — equal-and-opposite amounts across two accounts
  within 3 days link structurally via `transfer_id` (and drop out of spending
  reports). A false pair can be undone through the `coffer:unlink-transfer`
  channel; the ledger-side unlink button is future work (the handler ships,
  the UI affordance does not yet). `transfer_id` is a raw self-pointer with no
  FK, so link integrity is enforced by convention: `softDeleteCofferTransaction`
  unlinks the survivor when one side is deleted (it never stays a
  silently-dropped transfer), and `listBrokenCofferTransferLinks`
  (queries-coffer-transactions.ts) reports every live row whose partner is
  missing, soft-deleted, or does not point back — the diagnostic that makes a
  desynced pair detectable.
- **Goals** — Mint's nine presets + custom, **balance-linked** (progress = the
  linked account's balance, optionally minus a creation-time baseline; debt goals
  measure pay-down). Two-way solve: a target date derives the required monthly,
  a monthly plan derives the projected finish. Manual accounts allowed.
- **Trends** — ONE parameterized report engine: measure (spending / income /
  net income / assets / debts / net worth) × group-by (category / group /
  merchant / account / tag / over time) × period (3M / 6M / 1Y / YTD), with the
  signature **two-click drill-down** from any bar to its transactions. Balance
  measures read the daily snapshot series.
- **Alerts** — seven rule types (low balance, bill reminders, unusual spending,
  over budget, fees ≥ $1, large purchases, price increases) + the **weekly/monthly
  digest**. The default-on set ships enabled ("an alert system that starts silent
  never gets configured"); unusual-spending starts off. Events are dedup-keyed
  per rule+entity+period (idempotent), stored as history in the Alerts view, and
  forwarded to **AMC's inbox** as dismissible cards via `raiseAgentAlert`.
- **Weekly digest** — the re-engagement card: balances, budget pacing vs month
  position, where the money went, notable transactions, coming-up bills, goal
  progress — one inbox card per period (`coffer-digest:<period>` dedup key).
- **The intelligence pass** — `runCofferMaintenance` (in
  `src/main/services/coffer/coffer-intelligence.ts`) runs snapshots → transfer
  matching → series matching/detection → **holdings materialization**
  (Phase 4.2 extension step, anchor `post-series`) → alert evaluation →
  **insight generation** (Phase 4.3 extension step, anchor `post-alerts`) →
  digest. The two extension steps self-register via
  `registerCofferMaintenanceStep` rather than being hardcoded into the core
  sequence, so a Coffer build with neither feature touched runs the original
  five-step pass byte-identically. It triggers after every import commit
  (unthrottled) and behind Overview loads (throttled to 6h); deliberately NO
  startup task or cron, so an unused feature costs nothing. The Alerts view's
  "Check now" forces a pass.

#### Phase 3 — Bank sync (SimpleFIN)

- **The model** — the user buys a SimpleFIN Bridge subscription themselves
  (about $15/year, paid to SimpleFIN), links their banks at the bridge, and
  pastes the bridge's ONE-TIME setup token into the panel's **Bank sync**
  section. AMC never sees a bank credential; screen scraping is banned forever
  (research doc 30). Teller remains a possible later second channel; file
  import stays the universal fallback.
- **The credential** — the claim exchanges the token for an access URL whose
  userinfo embeds the Basic-Auth pair. It is stored as the encrypted
  `simplefinAccessUrl` settings field (safeStorage `enc:` at rest, in
  `ENCRYPTED_APP_SETTINGS_KEYS` + `CLI_SETTINGS_SENSITIVE_KEYS`), read ONLY
  through `services/coffer/sync/simplefin-credential-store.ts`, and never
  appears in any IPC response, log, or renderer state — the UI learns only
  `connected` plus the bridge hostname.
- **Linking** — the provider roster (a cheap balances-only probe) is linked
  account-by-account: create a typed new Coffer account or ADOPT an existing
  manual one. The link key is `coffer_accounts.provider_account_id` (partial
  unique index: one provider account feeds at most one live ledger).
- **Sync runs** — manual ("Sync now"; deliberately no cron while unreleased).
  One bridge fetch covers all accounts (90-day first backfill, then
  watermark-minus-7-days incremental windows, idempotent via native-id dedup).
  Per account: same-id pending settles → dedup → **pending→posted
  reconciliation** (exact amount, ±5 days, single-candidate-only — never
  guesses between two identical pending charges) → `importCofferRows` (the one
  import path: payees, rules, review queue; pending rows enter as `pending`) →
  the provider's balance applied LAST as authoritative (the daily snapshot
  carries it) → `last_synced_at` + `coffer_meta` watermark stamps. Then the
  intelligence pass runs unthrottled, exactly like an import commit.
- **Failure honesty** — sync is treated as unreliable-by-nature: per-account
  failures isolate into that account's row; a whole-run failure records
  `sync:simplefin:last-error` for the health table and surfaces humanized
  (never a raw HTTP status or provider JSON). A failed run never advances the
  watermark, so its window is re-covered next time.
- **Disconnect** — confirm-guarded; forgets ONLY the credential. Accounts,
  history, and provider links survive, which is what makes a later re-connect
  relink cleanly instead of duplicating.

## Where to find it

Coffer is a sidebar row, not a settings page: once it is enabled, a **Coffer** entry appears in the
sidebar's **Productivity** group and clicking it opens the finance panel full-screen. The switch
that reveals it is in **Settings → Lab**, and while the feature is in development it ships hidden,
so that toggle is the first thing to look for.

## How it behaves

### How to use (current state)

1. Settings → Lab → enable **Coffer** (or `AMC_SHOW_COFFER=1`).
2. The Coffer row appears in the sidebar's Productivity group; click it to open the
   panel.
3. Import a bank CSV/OFX export, add accounts/transactions manually, or connect
   a SimpleFIN bridge in **Bank sync** to pull them automatically.

### Per-record flags the desktop can set

Four flags are persisted on every Coffer record and were long settable only from the
mobile client — the desktop rendered no control (`docs/superpowers/plans/2026-08-19-coffer-desktop-gaps.md` §A):

| Flag                          | Where                                       | Effect                                                                              |
| ----------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------- |
| **Off budget** (account)      | Accounts — add row + edit sidebar           | Account stays in net worth; its spending never counts toward a budget. Pre-ticked for investment / loan / property / vehicle / other asset+debt. |
| **Hide from reports** (category) | Rules & Categories — per user subcategory | Mint's "Hide from Budgets & Trends" — category stays pickable, leaves every report and budget roll-up. Distinct from **Hide**, which only removes it from the pickers. |
| **Stop after this** (rule)    | Rules & Categories — per rule row           | Ends the rule chain on a match, so no later rule runs. This is what makes rule ORDER mean anything. |
| **Cleared** (transaction)     | Transactions — edit sidebar, Save details   | Reconciliation state: this row was matched against the bank statement.               |

Only `excludeFromReports` needed a new channel
(`coffer:set-category-exclude-from-reports`) — the other three already had one.

### Retirement paths that used to be dead ends

Four handlers were registered, Zod-validated and reachable but had ZERO renderer call
sites (§B of the same plan), so the capability existed with no way to use it:

| Action                | Where                                        | Behaviour                                                                                   |
| --------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------- |
| **Remove budget**     | Budgets — per budget row (tracking mode)      | Was create-and-edit only; the nearest gesture was setting it to `$0`, which still renders a bar. Soft-delete keyed by category + month, so other months keep theirs; the spend falls into "Everything else". |
| **Rename** (category) | Rules & Categories — inline on a user subcategory | Categories could not be renamed anywhere. Seeded defaults stay fixed (Mint's fixed-tree rule) and the handler refuses them. |
| **Delete** (category) | Rules & Categories — per user subcategory     | Hiding was the only retirement path. F040: live transactions are REASSIGNED to Uncategorized (never dropped) and the category's budgets are soft-deleted — the confirm copy says so. |
| **Unlink transfer**   | Transactions — edit sidebar, transfer-linked rows only | A wrongly-paired transfer took BOTH rows out of spending reports with no way to undo it. Clears `transfer_id` on both halves; neither row is deleted. |

All three destructive ones route through `useConfirmDialog`; every one surfaces a
rejected write via `surfaceIpcError` rather than reporting a silent success.

### The rule builder

Rules & Categories → **+ New rule** (or **Edit** on a row) opens `CofferRuleEditor`. Before
it existed the ONLY rule producer was `buildLearnedCategoryRule` — the "always use this
category for this payee" loop — which emits exactly one shape (`payee is X` →
`set-category Y`, `default` stage, one condition). That left most of a live, tested engine
unreachable: 1 of 7 condition fields, 1 of 9 operators, 1 of 5 actions, 1 of 3 stages.

The builder exposes condition rows (field / operator / value), action rows, all/any
matching, the run stage, and stop-after-this. Two deliberate constraints, both enforced by
`coffer-rule-editor-helpers.ts` and its tests:

- **A field only offers operators the engine can satisfy for it.** `greater-than` /
  `less-than` are guarded by `typeof raw === 'number'` in `cofferConditionMatches`, and only
  `amount` projects to a number — so `date` (a `'YYYY-MM-DD'` STRING) gets the string
  operators instead, and `contains "2026-08"` is the honest way to say "in August 2026".
  Offering the numeric ops there would build a rule that silently never fires.
- **`matches` (regex), `one-of` and `not-one-of` are omitted.** Engine-supported, but a raw
  regex box is a footgun and one-of is covered by two conditions joined with "any".

Amounts are entered in signed dollars and sent as signed minor units — spending is
negative, so the editor echoes what a pairing will actually catch ("Matches money OUT of
more than $200.00") rather than leaving the user to infer it from the sign.

### How rules are ordered

Rules run one after another and later matches OVERWRITE earlier ones, so order is the
semantics. Three keys decide it, in priority:

1. **Stage** — `pre` → `default` → `post`.
2. **Specificity, ASCENDING** — broad rules run FIRST so the precise rule applies last
   and wins. An exact match (`is`) outscores a fuzzy one (`contains`); more conditions
   score higher still. So *"bank description contains WHOLE → Groceries"* runs before
   *"merchant is Whole Foods → Organic Food"*, and the second corrects the first.
3. **`sortOrder`** — the user's manual position, reached ONLY when stage and specificity
   both tie.

The Rules list renders through the same `orderCofferRules` the engine runs (shared, not
mirrored — see [coffer-rule-order-contract.md](../../.claude/memory/contracts/coffer-rule-order-contract.md)),
numbers each row by execution position, and states the rule in prose above the list.

A drag handle appears **only inside a tie group** — a run of rules that stage and
specificity cannot separate. Everywhere else the manual position is never consulted, so
a drag there would change the stored value and change nothing about execution.

### Phase 4.1 — flex budgeting

Monarch-style partition inside the Budgets view (Category ⇄ Flex toggle; no new
nav section). Every spending category classifies at READ TIME into
fixed / non-monthly-recurring / flexible from its trailing-6-month share of
spend linked to recurring series (Phase 2 linkage): ≥60% monthly-or-faster
cadence → fixed, ≥60% longer cadence → recurring, else flexible;
`coffer_categories.flex_bucket_override` beats the computation and "Auto"
clears it (NULL). Bucket-level budgets live in `coffer_bucket_budgets`
(month-keyed history rows, rollover semantics identical to category budgets
with an implied monthly frequency). Pure engine
`services/coffer/flex-engine.ts`, read model `services/coffer/flex-month.ts`
(`buildCofferFlexMonth`), channels `coffer:get-flex-month` /
`coffer:set-flex-override` / `coffer:upsert-bucket-budget` /
`coffer:delete-bucket-budget` (WS-blocked; mutations cli-parity-exempt as
feature-scoped-surface). Determinism is contract-locked:
`coffer-flex-classifier-contract.md`.

CLI routes and the full IPC surface are added as the feature builds out — see the
cli-parity manifest for current coverage.

### Phase 4.2 — Holdings, trades & benchmarks

Manual buy/sell trades on investment accounts (`coffer_trades`, symbol
autocomplete over `coffer_securities`, created on first use). Holdings are a
materialization (`coffer_holdings`) rebuilt by the `holdings-materialization`
intelligence step (anchor `post-series`): average-cost basis, oversells clamp
at zero and set `coffer_trades.is_inconsistent` (recomputed every pass).
Quantities are integer micro-shares (1e-6); valuation is
`round_half_away_from_zero(quantity_micro × close_minor / 1e6)` in BigInt —
see `.claude/memory/contracts/coffer-holdings-integer-purity-contract.md`.
Accounts with trades AND `sync_source = 'manual'` get their balance set to
holdings value at latest prices each pass; synced accounts stay authoritative.
Quotes: Yahoo Finance v8 chart JSON (`quotes-client.ts` wire facts; replaced the
Stooq CSV feed on 2026-08-10 after Stooq gated it behind a JS anti-bot
challenge), opt-in via `cofferQuotesEnabled`, manual refresh + 24h throttle
behind Investments loads, per-symbol isolation, `coffer_meta` watermark
(`quotes:last-run-at`) that a fully-failed run never advances. Benchmark = `^SPX`
stored, mapped to Yahoo's `^GSPC` at fetch time (`is_benchmark = 1`,
shared price table). Views: Investments section (portfolio value, allocation
donut, vs-benchmark, holdings table with stale badges, trades ledger, consent
card). Out of scope: broker-CSV import, FIFO/tax lots, any trading.

Channels: `coffer:list-securities` / `coffer:list-trades` /
`coffer:create-trade` / `coffer:update-trade` / `coffer:delete-trade` /
`coffer:refresh-quotes` (all reserved by the Phase
4.0 foundation) plus `coffer:get-portfolio` (the combined portfolio + series +
benchmark + allocation payload the Investments view reads, added fresh by
this feature — not part of the 4.0 reservation batch). The foundation also
reserved `coffer:list-holdings`, REMOVED in the §B pass: `coffer:get-portfolio`
returns the same `CofferHoldingView[]` from the same builder after the same
quote refresh, so it was a duplicate read whose only extra was an `accountId`
filter the renderer can do in one line — and it had no call site. All WS-blocked;
`create-trade` / `update-trade` / `delete-trade` / `refresh-quotes` are
cli-parity-exempt as feature-scoped-surface (the rest are reads, out of the
command surface). `coffer:list-securities` takes no query param — it lists
the whole registry and the trade form's autocomplete filters client-side.

### Phase 4.3 — Insights

Six generated insight types (fees-paid, category-trend, spending-projection,
top-merchants, subscription-creep, savings-rate) computed from the ledger only
by `src/main/services/coffer/insights-engine.ts` — pure generators + ONE
intelligence step at anchor `post-alerts` (before the digest step, so the
weekly digest can embed the top 1–2 undismissed insights as "Spotlights").
Persistence is the alert-event pattern on `coffer_insights`: unique
`dedup_key` (`coffer-insight:<type>:<entity|_>:<period>`), INSERT OR IGNORE,
dismissal is a per-period tombstone. Thresholds live as named constants in the
engine (20% + $50 floor for category trends, $50 floor + day-7 minimum for the
spending projection, $10/$50 increase floors for subscription creep). The fee
identification is shared verbatim with the fee-charged alert via
`isFeeLikeTransaction` (alerts-engine.ts) so the two can never drift.

IPC: `coffer:list-insights` / `coffer:dismiss-insight` (both reserved by the
Phase 4.0 foundation, WS-blocked; `dismiss-insight` is cli-parity-exempt as
feature-scoped-surface, `list-insights` is a read out of the command surface).
UI: the Insights section is a card feed, severity DESC then created_at DESC,
with per-card dismiss (permanent for that period) and a deep link into the
view that explains it — category-trend auto-drills Trends via the
`CofferTrendsIntent` prop; fees-paid/top-merchants/savings-rate open Trends
pre-filtered; spending-projection opens Budgets; subscription-creep opens
Recurring.

### Phase 4.4 — Receipt attachments

Image/PDF receipts attached to a transaction from the ledger edit sidebar's
Attachments grid (add via native picker, thumbnail tiles, safe open, guarded
delete). Storage is hash-addressed and reference-counted
(`services/coffer/attachments-store.ts`): bytes are written once per SHA-256
content hash to a flat dir (`getCofferAttachmentsDir()`), so attaching the
same file to two transactions stores it once, and a file is only physically
unlinked once no live `coffer_attachments` row still references its hash
(deleting a transaction cascades the same ref-count check across its split
children). The canonical on-disk extension comes from the content-VALIDATED
MIME, never from user input or the picker's reported name. A candidate is
gated by extension allow-list, the 20 MB size cap (checked via `stat` BEFORE
the file is read into memory, rejecting an oversized pick cheaply), and a
content-signature sniff against the declared MIME (`fileBytesMatchDeclaredMime`)
— PNG/JPEG/GIF/WebP/PDF are fingerprinted, HEIC has no signature and passes on
extension alone. The renderer never supplies bytes or a filesystem path for
ADD (the native picker runs in Main); OPEN resolves the on-disk path
server-side from the row id and routes through `openPathSafely`, never trusts
a renderer-controlled path. Attachment metadata (filename/mime/size/hash) also
rides the versioned JSON backup export alongside the rest of the ledger.

Channels: `coffer:add-attachment` / `coffer:list-attachments` /
`coffer:open-attachment` / `coffer:delete-attachment` (all reserved by the
Phase 4.0 foundation, WS-blocked; add/open/delete are cli-parity-exempt as
feature-scoped-surface, list is a read out of the command surface).

### Phase 4.5 — Envelope mode

Opt-in YNAB-style allocation-first budgeting behind `cofferBudgetMode`
(`'tracking'` default). `coffer_budgets.mode` partitions the table: tracking
rows and envelope rows coexist per month+category and neither mode's queries
ever read the other's rows (`queries-coffer-budgets.ts` is mode-scoped
throughout). Math lives in `envelope-engine.ts` (pure):
`available = assigned − activity + prior available` (carries BOTH directions);
`To Be Budgeted = on-budget cash − Σ available` — always computed, never
stored. The composition layer is `envelope-month.ts`.

**Money-conservation invariant (same-PR invariant contract):**
`moveCofferEnvelopeMoney` updates two `mode='envelope'` rows inside ONE
better-sqlite3 transaction; a move never creates or destroys cents (Σ assigned
unchanged) and a mid-move failure rolls the whole move back. Locked by
`tests/unit/services/coffer/envelope-conservation-invariant.test.ts` — if that
suite fails, fix the mutation path, never the test.

In envelope mode the over-budget alert rule evaluates _available_ (negative
envelope = over); tracking mode's alert inputs are byte-identical to Phase 2.

IPC: `coffer:assign-envelope` (direct edit, `IPC.COFFER_ASSIGN_ENVELOPE`) and
`coffer:move-envelope-money` (`IPC.COFFER_MOVE_ENVELOPE_MONEY`) were both
reserved by the Phase 4.0 foundation batch — note the assign channel's real
name differs from a naive "set-envelope-assigned" guess, so check
`src/shared/ipc-channels/coffer.ts` before assuming a channel name.
`coffer:get-envelope-month` (`IPC.COFFER_GET_ENVELOPE_MONTH`) was NOT part of
that batch (no composed-view GET was reserved) and was added by this feature
build itself, mirroring the `COFFER_GET_PORTFOLIO` / `COFFER_GET_FLEX_MONTH`
"GET returns a composed view" convention. All three are WS-blocked; the two
mutations are cli-parity-exempt as feature-scoped-surface (the get is a read,
out of the command surface).

### Phase 4.6 — AI money assistant (read-only)

A **14th "Assistant" section**: a read-only, grounded natural-language Q&A over
the user's own ledger ("where did my money go last month?", "am I over budget?",
"what do my subscriptions cost?"). Default OFF behind the `cofferAssistantEnabled`
consent setting (an in-panel `CofferAssistantConsentCard` names the cost + privacy
before enabling); an "Ask" card on the Overview deep-links into it.

- **Grounded tool-use.** The service (`src/main/services/coffer/assistant/`) wires
  Coffer's AGGREGATE read engines in as read-only TOOLS for the shared
  `runAnthropicToolLoop` primitive: the model calls a tool → the tool runs locally
  against SQLite → returns a small aggregate → the model composes the answer.
  Numbers come from the tools, never the model's arithmetic. Seven tools:
  `query_trends`, `get_spending_by_category`, `get_budget_status`, `get_cashflow`,
  `get_net_worth`, `get_recurring_and_bills`, `get_goals`.
- **Aggregates-only egress (privacy).** `coffer-assistant-tools.ts` imports NO
  raw-transaction reader (`listCofferTransactions` / `getCofferTransaction` /
  `listCofferLargestOutflows` / `listCofferFeeCandidateOutflows`, and NOT
  `buildCofferOverview` which embeds `recentTransactions`), and every tool result
  is labels + formatted money + entity-grain rollups with no `id`/raw date — so raw
  ledger rows structurally cannot reach the model. Locked in code + a test, not by
  prompt wording → [coffer-assistant-egress-contract.md](../../.claude/memory/contracts/coffer-assistant-egress-contract.md)
  (`tests/unit/services/coffer/coffer-assistant-egress.test.ts`). It is a
  prompt-egress surface in `docs/PII_INVENTORY.md`.
- **Credential + cost.** The BYO Anthropic API-key path
  (`resolveApiKeyForAiFeatures`, same as Drive/Sheets/Calendar); no key configured →
  a friendly `no_api_key` state, no call. Default model `MODEL_HAIKU_LATEST`; cost
  tracked inside the loop under the unique label `coffer-ai` (per-feature-billing
  §I3); reuses the shared `ANTHROPIC_BREAKER_KEY` breaker + daily-$ cap. Errors
  humanized (`humanizeAiCallError`).
- **Wiring.** One channel `coffer:assistant-ask` (`IPC.COFFER_ASSISTANT_ASK`),
  Zod-validated + `wrapHandler`'d in `coffer-handlers.ts`, gated on
  `cofferAssistantEnabled`; single request/response (no streaming). WS-blocked (a
  paired phone can't reach a paid call over the ledger) and cli-parity-exempt as
  `feature-scoped-surface` ("ask" is a command verb, so an entry is required). The
  handler always returns `success: true` with a discriminated `status`
  (`ok` / `no_api_key` / `disabled` / `offline` / `unavailable`) so the renderer
  branches without parsing an error. Gated under the existing `coffer`
  unreleased-feature (no new feature entry).

## For agents

### Wiring (for AI agents)

- Virtual project: `COFFER_PROJECT_ID = '__coffer__'` (`src/shared/virtual-project-ids.ts`);
  NON-spawnable (no Claude sessions).
- Settings slice: `src/shared/types/settings/coffer-settings.ts` (`cofferEnabled`,
  default false).
- Gate: `UNRELEASED_PROJECT_GATES` in `src/renderer/src/stores/project-visibility.ts`
  (featureId `coffer`).
- Manifest: `src/shared/integrations/coffer.ts` (`parentGroupId: 'productivity'`).
- Data: local SQLite tables (`coffer_*`) in the app DB; money stored as integer minor
  units per the monetary-integer-twin contract.
- Bank sync: `src/main/services/coffer/sync/` (protocol client · credential store ·
  pending-reconciliation policy · orchestrator); channels `coffer:sync-*` (all
  WS-blocked; mutations cli-parity-exempt as feature-scoped-surface); the
  SimpleFIN wire facts are recorded at the top of `simplefin-client.ts`
  (verified against simplefin.org/protocol.html, 2026-07-30).

## Related

Nothing here leaves the app or needs a bank login, so the questions a reader arrives with are
usually about where the data is kept and how to get it out. The app's wider backup and restore
story is on the [portable backup](portable-backup.md) page, and the encryption guarantees behind
your local data are on the [is my data encrypted](is-my-data-encrypted.md) page. What the app
spends on AI features like the read-only assistant is covered on the
[cost control](cost-control.md) page.
