Browser (PWA)
└── Vue 3 SPA (frappe-ui + Tailwind)
├── /api/method/* → Frappe whitelisted methods
├── /api/resource/* → Frappe REST API
├── WebSocket (Redis) → realtime Feed refresh
│ └── Frappe (Gunicorn) ── MariaDB
└── SSE → expenso-assistant service (Assistant chat stream + /resume)
├── LangGraph agent ──── binds tools.py functions directly ──── OpenAI
│ └── tools.py ── Frappe REST /api/method/* ← as the Member (bearer passthrough)
├── FastMCP server (/mcp) ── registers the same tools.py functions
│ └── external ChatGPT/Claude connectors (pure adapter — disableable)
├── Postgres (LangGraph checkpointer + daily token counter)
└── traces ──> hosted LangSmith, US (receipt images masked)
Frappe scheduler ──(per-Member read token)──> expenso-assistant /run/proactive
flowchart TD
Member["Family Member"]
App["Expenso PWA<br/>Feed · Analytics · Budget · Settings"]
ChatUI["Assistant chat<br/>(in-app tab)"]
Frappe["Frappe backend<br/>auth + ledger + realtime — system of record"]
Assistant["expenso-assistant service<br/>LangGraph agent + tools"]
LLM["OpenAI"]
Ext["External ChatGPT / Claude connectors"]
Member --> App
Member --> ChatUI
App -->|"read / write ledger"| Frappe
Frappe -->|"live refresh (WebSocket)"| App
ChatUI -->|"chat (SSE)"| Assistant
Assistant -->|"tool calls as the Member"| Frappe
Assistant --> LLM
Assistant -->|"proposed writes need confirmation"| ChatUI
Ext -->|"OAuth (read / write scopes)"| Assistant
Frappe -->|"scheduled proactive run"| Assistant
Frappe stays the OAuth authorization server and the system of record for the ledger. It holds no LLM dependency or key, and stores nothing about the Assistant. Conversation threads live in the service's Postgres; every LLM call and its cost live in LangSmith traces (there is no LLM Call Log DocType — ADR 0008's 2026-09-10 update; LangSmith since ADR 0011). See docs/adr/0008-in-app-assistant-architecture.md and docs/adr/0011-hosted-langsmith-tracing.md.
- Custom Frappe app (
expenso): all DocTypes, APIs, and business logic - MariaDB (Frappe default)
- Redis for caching + Frappe realtime (WebSocket) for live Feed updates
- Frappe's built-in session auth for the app; Frappe's built-in OAuth2 provider is the authorization server for the MCP connector and the in-app Assistant (
expenso-assistantservice). No separate auth service.
- Custom Vue 3 SPA in
frontend/— not the Frappe Desk UI - frappe-ui component library + Tailwind CSS (mobile-first, responsive)
- Vite for build/HMR
- Deployed as a static bundle served by Frappe's Nginx
- PWA (Vite PWA plugin) — installable on Android/iOS home screens
| Field | Type | Notes |
|---|---|---|
family_name |
Data | display name |
currency |
Link → Currency | single currency per family, set at creation |
members |
Table (Child) | links to User; two Members expected |
| Field | Type | Notes |
|---|---|---|
user |
Link → User | the Member |
| Field | Type | Notes |
|---|---|---|
amount |
Float | required |
date |
Date | defaults to today |
category |
Link → Category | optional; get_query scopes to Family |
notes |
Small Text | optional; free text on what the Expense was for |
family |
Link → Family | required |
No split_among — there is no split or debt tracking.
| Field | Type | Notes |
|---|---|---|
category_name |
Data | display label, e.g. "Groceries" |
family |
Link → Family | required |
name |
— | auto (naming series CAT-.####) |
category_name is not the name key — two Families can each have "Groceries".
Defaults seeded via Family.after_insert: Groceries, Dining, Transport, Utilities, Health, Entertainment, Shopping, Other.
Amounts displayed via Intl.NumberFormat with the Family's currency code.
| Field | Type | Notes |
|---|---|---|
amount |
Float | required |
date |
Date | defaults to today |
source |
Link → Source | optional; get_query scopes to Family |
notes |
Small Text | optional; free text on what the Income was for |
family |
Link → Family | required |
| Field | Type | Notes |
|---|---|---|
source_name |
Data | display label, e.g. "Salary" |
family |
Link → Family | required |
name |
— | auto (naming series SRC-.####) |
Defaults seeded via Family.after_insert: Salary, Freelance, Rental, Other.
Savings is never stored — always computed as Income − Expenses for the selected month.
Income records for the selected month are fetched via a whitelisted get_income method (mirroring get_expenses), so Feed can list and interleave them with Expenses; realtime events income_created / income_updated / income_deleted refresh Feed the same way the existing expense_* events do.
Named Expenso Budget, not Budget — the plain name collides with ERPNext core's own Budget DocType when both apps are installed on one site (issue #83, ADR 0007). The domain term is still "Budget" everywhere user-facing.
| Field | Type | Notes |
|---|---|---|
category |
Link → Category | required; get_query scopes to Family |
amount |
Float | required; spending cap for this Category, for this month only |
month |
Int | required; 1–12 |
year |
Int | required |
family |
Link → Family | required |
One Budget per Category per Family per (month, year) — enforced in validate.
Budget is scoped to a single month. When a month has no Budget row for a Category, the
backend resolves the effective amount by carrying forward the most recent earlier month
that has one (_resolve_budget_amount in api.py) — no forward-looking carry, and gaps
are not backfilled. get_analytics uses this resolution read-only. The Budget screen's
get_budgets additionally materializes a real row for the requested month the first time
it's viewed (copying the carried-forward amount), so it becomes editable independently of
other months. Deleting a month's Budget only removes that month's row — carry-forward for
later months resumes from whatever row precedes it. Independent of Income.
Budget Status (computed on read, never stored):
| Spent / Budget | State | Display |
|---|---|---|
| < 80% | Normal | no indicator |
| ≥ 80% | Warning | yellow |
| ≥ 100% | Exceeded | red |
Expense and Income each gain:
| Field | Type | Notes |
|---|---|---|
is_external_write |
Check | set only by the connector write path (ADR 0006); drives the "unreviewed external write" marker in the detail view |
external_write_message |
Small Text | the calling LLM's verbatim request text; stored for audit, never rendered in the app |
Auth: a single admin-configured OAuth Client (standard Frappe DocType) with scopes expenso:read / expenso:write. No new expenso DocType.
The expenso-assistant service records every turn (chat, insights, receipt extraction) as a LangSmith trace, in the US region (ADR 0011). LangChain's stock LangChainTracer nests every model call, tool call, middleware step and confirm-card pause under the turn's root run. Receipt images are masked before upload; amounts, categories, notes and member emails are not. There is no LLM Call Log DocType and Frappe stores nothing about the Assistant (ADR 0008's 2026-09-10 update). (metadata.family was dropped by the 2026-09-10 P6-S5 update — the service has no server-side path to the Family and nothing in v1 slices by it.) Each trace carries:
| On the root run | Value |
|---|---|
| name | <feature>-turn for the message; <feature>-resume for the confirm-card decision, which keeps the turn's feature (receipt-resume for a receipt) |
| tag | feature:chat / feature:receipt / feature:insights |
metadata.user_id |
the Member the call was for |
metadata.feature |
chat / receipt / insights |
metadata.session_id |
the thread id — groups a Member's turns into one LangSmith thread |
| cost | LangSmith's own figure, from the token counts on each model call |
receipt_accuracy_{amount,date,category,notes} feedback |
receipt-resume traces only — proposed-vs-confirmed field agreement (ADR 0003) |
Admin cost / latency / token visibility is LangSmith's monitoring, filtered by the feature:<x> tags. The per-Member daily token cap is a Postgres counter, not a trace query (ADR 0008's 2026-09-11 update); there is no app-level monthly spend cap (the OpenAI account's hard limit is the backstop).
| Field | Type | Notes |
|---|---|---|
entry_method |
Select | manual / assistant / connector / receipt — provenance, orthogonal to is_external_write. Only connector records are marked "unreviewed". |
Conversation threads (LangGraph checkpointer's Postgres, in the expenso-assistant stack), full agent traces, and every LLM call + its cost (all LangSmith). "Clear chat" deletes the LangGraph thread. There is no Chat Message, no Chat Run, and no LLM Call Log DocType.
The expenso-assistant repo is a standalone service (structured like the sibling contract-intelligence project). See docs/adr/0008-in-app-assistant-architecture.md for the full rationale.
tools.py— one module of typed async functions, the single tool definition (reads:get_expenses/get_analytics/get_income/get_budgets/list_categories/list_sources; writes:create/update/delete_expense,create/update/delete_income,add_category,add_source,set_budget). Each calls Frappe's REST API as the Member (bearer passthrough).- LangGraph agent — MIT framework; the Elastic-licensed
langgraph-apiserver is not used. Hand-rolled FastAPI endpoints serve it:POST /chatdrivesastream_events(version="v2")→ SSE, andPOST /resume→graph.ainvoke(Command(resume=…))→ a fresh SSE stream. Postgres checkpointer (AsyncPostgresSaver). The graph is a hand-rolledStateGraph— anagentnode (model bound with thetools.pyfunctions directly, no MCP) ⇄ aToolNode, with atool_call_countin state feeding the max-tool-calls cap. Interactive turns bind read+write tools; scheduled (proactive) runs bind read-only tools. - Write confirmation (P6-S7) —
route()sends a message with any write tool-call to aproposenode (read-only calls still go totools).proposeharvests every write call in the message, reads eachupdate/deletetarget's current values +modifiedout of the tool history already in state, and raisesinterrupt({actions: [{id, tool, kind, entity, summary, changes|values}]})— one batched confirm card, capped atmax_proposed_writes_per_turn(25).POST /resumecarries{decision: {selected: [id,…]}}(empty = cancel);proposere-derives the action list from the still-pendingtool_callsand calls thetools.pywrite fns for the selected subset withif_modified_since= the value the agent read, oneToolMessageper original call. A per-actionTimestampMismatchErrordoesn't abort the batch — the rest apply, the conflict returns as aToolMessage, the model re-reads and re-proposes (a secondneeds_confirmation). No MCP elicitation on this path — that stays on the FastMCP adapter for external connectors. Confirmed writes setentry_method="assistant", nois_external_write. - SSE event set —
step(humanized tool-call, one line per action),token(answer delta),done({message_id}),needs_confirmation({actions}— ends the leg with nodone; the member answers via/resume),error({code, message}—recursion/tool_cap/wall_clock/daily_cap/tool_error/internal). On anyerrorthe turn is rolled back to the pre-run checkpoint, so a capped/failed turn leaves no orphaned message in history. A new/chatwhile aninterruptis pending discards it (a transientstep) and proceeds. - FastMCP server (
/mcp) — registers the sametools.pyfunctions for external ChatGPT/Claude connectors, gating every write behind an SEP-2322 input-required confirmation (the2026-07-28MCP era replacement for server-initiated elicitation; the connector renders its own confirm UI). A pure external adapter, config-flag gated — disabling it does not affect the in-app Assistant. - Auth — a FastAPI dependency introspects the Frappe OAuth bearer (RFC 7662; rejects inactive), requires
expenso:read, and resolves the Member viafrappe.auth.get_logged_user. The thread is 1:1 with the Member:thread_id = "member:" + sha256(email), derived server-side on every call — no client ever supplies a thread id, so one Member cannot address another's thread. History is the messages in the thread's latest checkpoint; "Clear chat" deletes the thread from the checkpointer. - Observability — hosted LangSmith, US region (ADR 0011), through the stock
LangChainTracer. Every trace is taggedfeature:<x>withuser_id(Member) /feature/session_idmetadata. Receipt images are masked by the client'shide_inputshook. Tracing is off whenLANGSMITH_API_KEYis empty. This is the only record of a call. - Cost bounds — per-run
recursion_limit/ tool-call / wall-clock caps (the runaway guard); a per-Member daily token cap counted in the checkpointer's Postgres (ADR 0008's 2026-09-11 update); a per-turnmax_proposed_writes_per_turnbatch cap on the confirm card (local — P6-S7 replaced the daily write cap with this, ADR 0008's 2026-09-10 P6-S7 update). No app-level monthly spend cap — the OpenAI account's hard spend limit is the money backstop. - One process, one event loop — the
appcontainer runs the FastAPI endpoints + the agent + (optionally) the FastMCP adapter in one uvicorn. Fine at this scale, but proactive runs are scheduled off-hours so a tens-of-seconds batch graph can't stall a live chat SSE stream; grow into a separate worker before relaxing that.
At a glance:
flowchart LR
In["In-app chat · external connectors · scheduler"]
subgraph Svc["expenso-assistant"]
direction TB
Entry["FastAPI + FastMCP<br/>(entrypoints + auth)"]
Agent["LangGraph agent<br/>(confirm-gated writes)"]
Tools["tools.py<br/>(reads + writes)"]
Entry --> Agent --> Tools
end
In --> Entry
Agent <--> OpenAI
Tools -->|"as the Member"| Frappe["Frappe REST"]
Agent --> Obs["LangSmith + Postgres<br/>(traces, cost, threads)"]
Detail:
flowchart TB
PWA["Expenso PWA — Assistant chat"]
Ext["External ChatGPT / Claude connectors"]
Sched["Frappe scheduler"]
subgraph Svc["expenso-assistant service (one uvicorn process)"]
direction TB
FastAPI["FastAPI — api/main.py<br/>/health · SSE run · /resume · /run/proactive"]
Auth["Auth — validates Frappe OAuth bearer,<br/>scopes thread to the Member"]
MCPsrv["FastMCP server /mcp<br/>(external adapter, config-flag gated,<br/>SEP-2322 confirm on writes)"]
Agent["LangGraph agent — agent/graph.py<br/>state graph · proposal node -> interrupt()<br/>interactive: read + write tools<br/>proactive: read-only tools<br/>caps: recursion / tool-call / wall-clock"]
Tools["tools.py — one tool definition<br/>reads: get_expenses · get_analytics · get_income ·<br/>get_budgets · list_categories · list_sources<br/>writes: create/update/delete_expense ·<br/>create/update/delete_income · add_category ·<br/>add_source · set_budget"]
FClient["frappe_client.py — thin REST client,<br/>bearer passthrough"]
Obs["observability.py — LangSmith tracer,<br/>image masking, daily token cap"]
end
Frappe["Frappe REST /api/method/*<br/>(runs as the Member — all permission hooks apply)"]
OpenAI["OpenAI"]
PG[("Postgres<br/>LangGraph checkpointer — threads<br/>+ daily token counter")]
LF["LangSmith (hosted, US)"]
PWA -->|"SSE run + /resume"| FastAPI
Ext -->|"MCP"| MCPsrv
Sched -->|"per-Member read token"| FastAPI
FastAPI --> Auth
MCPsrv --> Auth
FastAPI -->|"astream_events / Command(resume)"| Agent
Agent -->|"binds directly"| Tools
MCPsrv -->|"registers same fns (no agent in this path)"| Tools
Agent <-->|"chat completions"| OpenAI
Agent -->|"checkpoint / resume"| PG
Agent --> Obs
Tools --> FClient
FClient -->|"bearer passthrough"| Frappe
Obs -->|"traces, images masked"| LF
Obs -->|"daily token counts"| PG
Agent -->|"needs_confirmation -> confirm card"| PWA
MCPsrv -->|"input-required -> connector's own confirm UI"| Ext
Pattern is identical for Expense, Income, and Expenso Budget:
- Custom role: Family Member
permission_query_conditions: filters tofamily = user's single familyhas_permission(): validates requesting user belongs to that Family- No
if_owner: 1— any Member may create, edit, or delete any record in their Family
Category and Source follow the same family-scoping pattern. Rename and delete happen only through Settings; adding a new one can happen either through Settings or inline from the Add Expense/Income sheet (same add_category / add_source API either way). Deleting is blocked while any Expense (for Category) or Income (for Source) still references the record — Frappe's built-in link-existence check raises LinkExistsError on frappe.delete_doc for any doctype with existing Link references, so no manual "is it in use" query is needed. Deleting a Category also deletes its Budget rows for every month, since a Budget has no meaning without its Category.
Bottom navigation bar with 5 tabs + FAB:
- Feed tab — home screen, unified monthly Expense + Income ledger
- Analytics tab — monthly financial summary (read-only), including Budget Status per Category
- Budget tab — set each Category's Budget amount for the selected month (editing only)
- Settings tab — Category/Source list management, logout, version
- Assistant tab — the Chat surface (full-screen conversation with the Assistant); carries an unread badge when the Assistant has posted an unseen Insight or pending proposal. (Phase 6; replaced the 2026-09-09 floating chat bubble — see
docs/adr/0008-in-app-assistant-architecture.md's 2026-09-10 P6-S6 update.) - FAB — bottom-right, on every screen except the Assistant tab (was Feed-only until the Assistant shipped); opens the Add Expense bottom sheet directly, with an Expense/Income tab switcher inside
No Family Switcher — a Member belongs to exactly one Family.
- Reverse-chronological, unified Expense + Income list for the selected month
- Grouped by date (headers: "Today", "Yesterday", "Jun 12"); within each group, Expense and Income rows interleave newest-first together (not clustered by type)
- Each row's primary line is Notes (bold/dark) when present, falling back to Category/Source (Expense) or "No source" (Income) when there is no Notes; Category/Source is otherwise shown as a smaller gray caption below Notes
- Income rows show their amount in green with a leading "+" (e.g. "+$500"); Expense rows are unchanged (dark, no sign)
- Tapping a row opens the matching Edit sheet — Edit Expense or Edit Income
- Summary banner at top shows two stats side by side: Spent this month, Income this month (no Net/Savings here — that stays on Analytics)
- Prev / next month navigation; month state shared with Analytics and Budget
- Silently refreshes via Frappe WebSocket on any add / edit / delete of an Expense or Income in the Family
- Phase 1: total spent + Category breakdown (name + amount, no charts)
- Phase 2 additions: Income total, Savings line (Income − Expenses)
- Phase 3 additions: Budget Status indicator per Category row, budget amount shown alongside spend; a total Budget stat (sum of each Category's effective Budget for the month)
- Stat tiles laid out as a 2×2 grid: Spent / Income on top, Savings / Budget below
- Category breakdown includes any Category with spend this month, an effective Budget this month, or both — a Budget with $0 spent still shows a row; sorted by amount spent descending (zero-spend rows sort last)
- Tapping a Category row with spend expands it inline (accordion) to list the matching Expenses for that month, newest first; a zero-spend row has no expand affordance. Multiple Category rows can be expanded at once. Each expanded Expense row shows its date, plus its Notes when present; tapping it opens the same Edit Expense sheet Feed uses
- Prev / next month navigation; month state shared with Feed and Budget
- Read-only in the sense that Analytics has no add/edit chrome of its own (no FAB, no inline editing controls) — editing a Budget happens on the Budget screen, adding Income/Expense happens via the Feed FAB. Expanding a Category to view and tap through to its Expenses does not count as an entry point in that sense; it reveals existing data and reuses Feed's Edit sheet rather than introducing new mutation UI
- Slides up from the Feed FAB (add, defaults to the Expense tab) or tapping an Expense row (edit)
- In add mode, an Expense/Income tab switcher sits at the top of the sheet; tapping "Income" swaps in the Add Income sheet in place. Not shown in edit mode.
- Fields, in order:
amount(required),date(defaults to today),notes(optional),category(optional) - Category select includes a trailing "+ New category" option; picking it reveals an inline text input in the sheet to name and create the Category without leaving Add/Edit Expense, then auto-selects it. Rename/delete are not available here — those stay on Settings.
- Delete action behind a confirmation prompt
- Dismissable by tapping outside or swiping down
- Same UX as Expense sheet, including the Expense/Income tab switcher in add mode
- Fields, in order:
amount(required),date(defaults to today),notes(optional),source(optional) - Source select includes the same trailing "+ New source" inline-create option as the Category select on the Expense sheet
- Reached from the Feed FAB by switching to the Income tab (no dedicated entry point of its own)
- Bottom nav tab, alongside Feed and Analytics
- Lists every Category in the Family with a button showing its Budget amount for the
selected month (or "Set Budget" if none) — tapping opens the same
BudgetSheetused previously from Settings, now scoped to the selected month - Prev / next month navigation; month state shared with Feed and Analytics
- Spend and Budget Status are not shown here — see Analytics
- Category list itself (rename, delete) is managed on Settings; adding a new Category can also be done inline from the Add Expense sheet (see above); this screen only reads the list
- Reachable via the Settings tab in the bottom nav (alongside Feed, Analytics, Budget)
- Phase 1: Category list — rename, delete (delete blocked while an Expense references the Category; also removes its Budgets). Adding a new Category can be done here too, or inline from the Add Expense sheet.
- Phase 2: Source list — rename, delete (delete blocked while an Income references the Source). Adding a new Source can be done here too, or inline from the Add Income sheet.
- Budget amounts are managed on the Budget tab, not here
- App version displayed in footer (read from a whitelisted API method at runtime)
A Member-facing "your usage this month" readout was planned here (old #73) and dropped for v1 (ADR 0008's 2026-09-10 update) — Members don't pay per call, so it is informational-only and waits for real demand.
- The 5th bottom-nav tab, labelled "Assistant" (icon 💬); routes to a full-screen screen (
pages/Assistant.vue), not an overlay or bottom sheet — scrolling message history + a pinned input above the nav bar - Header carries the "Clear chat" action (inline Confirm/Cancel, matching the Settings delete pattern)
- The service base URL reaches the frontend via Frappe's boot context (
window.assistant_urlfromfrappe.conf.expenso_assistant_url); when unset the tab shows an "Assistant isn't configured" notice - The frontend calls the service cross-origin (
https://assistant.<site>):useAssistant.jsmints a bearer viamint_assistant_token(read-only through P6-S6;write=Truefrom P6-S7 so the confirm-gated tools can run), then consumesPOST /chatas SSE viafetch()+ReadableStream(notEventSource— the bearer rides anAuthorizationheader). Event vocabularystep/token/done/needs_confirmation/errorper the service'ssession.py; the humanized step log is transient (shown while streaming, gone afterdone), andGET /historyreturns only user+assistant messages - The service requires a
CORSMiddlewareallowing the Frappe origin (companion change in theexpenso-assistantrepo) - One continuous private thread per Member; history read from the
expenso-assistantservice - Sending a message opens an SSE stream: a humanized step log ("Reading March expenses…") streams first, then the answer as prose
- A proposed write ends the stream with a
needs_confirmationevent;ConfirmCard.vuerenders inline as the last thread item — the literal row(s), a before→after diff for edits, a checkbox per action (checked by default), Confirm / Cancel. Batched: one card per turn for the whole proposed action set (capped atmax_proposed_writes_per_turn); Confirm/Cancel callresume({selected}), which opens a continuation SSE stream. The card goes read-only once resume starts - Attaching a photo → the agent proposes an Expense in a confirm card (receipts). The image is not stored
- "Clear chat" in the header (behind a confirmation) deletes the thread
- Proactive Insights and pending proposals appear inline as Assistant messages
- Selected month lives in a Pinia store; resets to current month on page refresh
- Feed, Analytics, and Budget all read from the same
monthstore key useExpenses,useCategories,useIncome,useBudgetscomposables own their respective API calls
The Vue SPA uses frappe-ui's LoginPage component, which calls Frappe's auth API (/api/method/login). Frappe sets the session cookie; the user never leaves the SPA. No separate auth service.
expenso/ ← Frappe app root (git repo)
├── docs/
│ ├── ARCHITECTURE.md ← this file
│ ├── IMPLEMENTATION_PLAN.md
│ ├── DEPLOYMENT.md
│ └── adr/
├── expenso/ ← Python package
│ ├── __init__.py ← __version__ bumped on every commit
│ ├── hooks.py ← permission_query_conditions, scheduler_events (Phase 7)
│ ├── assistant/ ← Phase 6: auth.py (mint_assistant_token), proactive.py
│ ├── expenso/api.py ← whitelisted ledger methods (the Assistant's tools call these via REST); connector-write marker/message/cap + Category-Source resolution live here
│ ├── expenso/permissions.py ← family-scoping hooks + require_oauth_scope (expenso:read / expenso:write guards)
│ └── doctype/
│ └── family/ · family_member/ · expense/ · category/ · income/ · source/ · expenso_budget/
│ (expenso/mcp.py + the frappe-mcp dependency — deleted in the P6-S4 cutover; the MCP server is now the FastMCP server in expenso-assistant)
└── frontend/ ← Vue 3 SPA
├── src/
│ ├── App.vue ← mounts BottomNav + Fab + the Add/Edit sheets globally
│ ├── pages/ ← Login · Feed · Analytics · Budget · Settings
│ ├── components/
│ │ ├── ExpenseSheet.vue · IncomeSheet.vue · BudgetSheet.vue · MonthNav.vue
│ │ ├── Fab.vue ← Phase 6: extracted from Feed.vue, global (all screens except Assistant)
│ │ ├── ConfirmCard.vue ← Phase 6 (P6-S7): renders interrupt `actions[]`, checkbox per action, Confirm/Cancel → `resume({selected})`
│ ├── pages/Assistant.vue ← Phase 6 (P6-S6): the Chat screen (5th nav tab)
│ ├── stores/month.js
│ ├── composables/
│ │ ├── useExpenses.js · useCategories.js · useIncome.js · useBudgets.js
│ │ ├── useEntrySheet.js ← Phase 6: shared Add/Edit sheet state (Fab.vue + Feed.vue)
│ │ └── useAssistant.js ← Phase 6: token mint + fetch/SSE stream parsing + unread badge; P6-S7 adds `needs_confirmation` handling + `resume()`
│ └── main.js
└── vite.config.js · package.json
expenso-assistant/ ← separate repo (arunjoyt/expenso-assistant, Phase 6+); uv, see ADR 0008
├── docker-compose.yml ← app + postgres + nginx (traces go to hosted LangSmith)
├── Dockerfile · nginx/ · .env.example · .github/workflows/ci.yml
└── src/expenso_assistant/
├── config.py ← OPENAI_MODEL + LANGSMITH_* + per-run caps + daily_token_cap + max_proposed_writes_per_turn (P6-S7) + MCP_ENABLED (env-driven; no monthly spend cap)
├── tools.py ← the one tool definition: typed async fns over frappe_client (ported from expenso/mcp.py); READ_TOOLS / WRITE_TOOLS
├── frappe_client.py ← thin REST client, bearer passthrough
├── auth.py ← FrappeTokenVerifier (RFC 7662 introspection) + OAuthProxy (PKCE); (P6-S5) resolve_member() via frappe.auth.get_logged_user + the FastAPI auth dependency + thread_id derivation
├── mcp_server.py ← FastMCP: registers tools.py fns for external connectors, SEP-2322 confirm on writes (mounted at /mcp iff MCP_ENABLED)
├── agent/ ← graph.py (`create_agent` + stock middleware since ADR 0010 — human-in-the-loop confirm card, tool-call limit, context editing, tool errors; binds tools.py directly) · middleware.py (the one custom middleware: prompt, receipt image, proactive instruction) · describe.py (confirm-card text per proposed write) · state.py (checkpointed state + per-run `RunContext`) · model.py (build_model — the one place OpenAI is named) · prompt.py (system prompt, today's date injected per-run; P6-S7 rewrites it for writes) · observability.py (LangSmith tracer + receipt-image masking + daily token cap) · session.py (SSE translation, turn rollback; P6-S7 wires resume_turn)
└── api/main.py ← FastAPI: /health · POST /chat (SSE) · POST /resume (SSE continuation, P6-S7) · GET/DELETE /history · (P7-S2) /run/proactive · mounts mcp_server · CORSMiddleware for the Frappe origin (allowed_cors_origins in config.py)