Field Notes

Chat agent

Parent: Architecture · Covers roadmap phase 7. Depends on designs 01–03. Status: as built — phase 7 shipped in felix-app; as-built notes are inline.

1. Scope and goals

The conversational surface over everything: chat about any insight, action, ticker, or story; ask open-ended questions; run what-ifs; and turn a discussion into a structured proposed action. Same grounding rules as the daily agent — the chat may reason and phrase freely, but every number comes from a tool.

Goals: answers grounded in tool results with visible citations; artifact-anchored threads (click an action → chat about it with full context); deterministic what-if analysis as a first-class tool; bounded cost per turn on the user's key.

Non-goals (v1): cross-thread "magic" memory (the fact pack and profile are the memory); voice; multi-user shared threads; the model transitioning action states (accept/dismiss stay human gestures in the UI).

2. Runtime and transport

Pydantic AI agent in the brain, best-tier model alias via the gateway on the user's virtual key, temperature low. Transport is SSE from FastAPI, proxied same-origin so the browser uses session cookies; the web-server proxy attaches the user's Supabase access token, and the SSE endpoint authenticates by verifying that JWT against the project JWKS (same mechanism as doc 03 §3) before streaming.

As built (phase 7): no general /brain/* proxy pre-existed — the plan's "proxied same-origin" assumption was wrong. Phase 7 built a chat-scoped streaming route handler (/brain/chat/[...path]) with a path-traversal guard and a /chat/-prefix assertion; it is deliberately not an open proxy, and non-streaming brain reads remain server-side RSC fetches. Phase 8 adds: chat requires BYOK for non-owner users (friendly refusal pointing at Settings → Keys — doc 05).

SSE event contract (frontend renders each):

EventPayloadUI behavior
deltatext chunkstream into the message
tool_statushuman label ("Checking your tax lots…"), started/finishedinline activity indicator
citationkind (fact/story/txn/action) + id + labelchip appended to the message, expandable
action_draftfull ProposedAction JSONinline card with Keep / Discard
usagetokens + cost this turnfooter + ledger row
errorfriendly message + reason coderetry affordance

Threads and messages persist (chat_threads, chat_messages with tool calls and citations as jsonb) so any conversation is replayable/auditable. When a thread outgrows its budget, older turns are rolled into chat_summaries (keep last K turns verbatim + summary of the rest). As built: the chat tables are brain-written only (SELECT-own RLS, no write grants to authenticated); the rollup triggers on token budget and keeps the last ~6 turns verbatim, with the summary generated on the cheap tier.

3. Context assembly (per turn)

  1. System prompt: persona (measured, fiduciary-style; plans over hot takes), grounding rules, output conventions, configurable not-financial-advice footer. As built: the persona shipped conversational — a user call overriding the terse-professional proposal — while keeping the fiduciary "plans over hot takes" framing and the full grounding rules; the disclaimer renders on the first assistant message of each thread.
  2. Profile summary (cached) + settings digest.
  3. Today's fact-pack digest — a compacted version of the daily pack.
  4. Artifact context if the thread was opened from an insight/action/ticker/story: full payload + provenance.
  5. Thread summary + recent turns.

Token budget is explicit per section with caps, so a long thread can't starve the fact digest.

4. Toolbelt

All reads go through the same lake module and Postgres repositories as the daily agent; user_id comes from the session, never from the model.

Portfolio: get_positions, get_lots(filters), get_performance(range), get_transactions(filters). Analytics: get_facts(kind, date?), explain_fact(id) (returns the engine's formula description from a static registry — transparency for "why do you say I'm off track?"), run_what_if(trades[]), get_retirement_projection(overrides). Market: get_prices(ticker, range, adjusted), get_ticker_info, search_stories(query, filters) (pgvector semantic search), get_story(id), get_recap(date). Actions: list_actions(state?), get_action(id), draft_action(proposal), add_action_note(id, note). Meta: get_data_freshness.

The two load-bearing tools:

  • run_what_if(trades[]) re-runs the deterministic engine on an in-memory copy of the portfolio: returns drift before/after, estimated tax, liquidity and concentration deltas. Pure reuse of phase-3 functions — "what if I sell 50 AAPL?" gets an exact, hallucination-free answer.
  • get_retirement_projection(overrides) re-runs the projection with parameter overrides — "what if I retire at 60 / save $500 more?" Same engine, different inputs, seeded and reproducible.

Mutation policy (deliberately tiny): draft_action writes a proposed action flagged source=chat — it must pass the same validators as daily actions (evidence, quantity provenance; what-if results count as citable evidence) and renders as an inline Keep/Discard card. add_action_note annotates. Nothing else mutates: accepting, dismissing, and completing are UI-only human gestures. As built: a fact-sourced draft reuses the daily agent's code-built params; a what-if-sourced tlh_swap code-sums the cited scenario's sell legs; fabricated citations are refused with zero rows written; the drafted rationale and note are faithfulness-scored too; the phase-5 dedupe/supersede machinery applies (supersede only ever touches a proposed row).

Loop policy: max 10 steps, parallel reads allowed, per-turn cost ceiling and per-day chat budget (settings) checked pre-flight against today's ai_runs rows (live per-run spend — usage_ledger is reconciled nightly and lags intra-day, doc 05 §3), with the virtual key's LiteLLM budget as the hard stop — friendly refusal with a link to Settings when exceeded. Tool errors surface honestly ("I couldn't load prices for X"). As built: ai_runs.cost_usd was NULL until phase 7 added a per-alias price table for the preflight; phase 8 made capture authoritative (spend-log settle for streaming chat, header elsewhere, estimate as last resort — doc 05 §2) and made the virtual-key hard stop real. The per-turn ceiling bounds output tokens.

5. Grounding and opinions

  • Every numeric claim must trace to a tool result or the pack; the eval harness samples conversations and verifies extracted numbers against logged tool outputs (same faithfulness check as design 02 §5). As built (phase 7): the number-provenance gate is a whole-turn union match — it reuses the daily faithfulness tokenizer and grounds every number in the turn against the pool of all tool results in the run's history, plus a citation-existence check; it is not the daily per-block scorer. Raw tool-result numbers ground via an internal [t:N] tag stripped from rendered prose. One documented heuristic chink: unitless small integers (≤12) are skipped unless adjacent to a share/$/%/ticker context (unit- and adjacency-aware after the phase-8 hardening) — misses fail toward silence, never fabrication. The same gate is imported verbatim by the Ticker AI take (doc 03 §6.4).
  • "I don't have that data" is the required behavior for gaps (e.g. intraday moves) — freshness metadata is always available to say as of when an answer is true.
  • "Should I buy/sell X?" gets a structured assessment, never a naked call: position in your plan (drift/concentration facts), tax situation (lots), relevant stories with sentiment, valuation context if fundamentals are enabled — composed into labeled pros/cons with citations, ending with how it relates to your model portfolio. Opinions are allowed; unlabeled opinions are not.

6. Template prompts

Seeded chips on an empty thread (config, user-extendable): "What happened to my portfolio this week?" · "Am I on track to retire at {age}?" · "What should I do with my available cash?" · "Run a tax-loss harvesting check" · "What if I sell {qty} {ticker}?" · "Explain this action" (artifact threads) · "What's moving markets today and does it matter for me?"

7. Open questions

All four were settled at the phase-7 kickoff.

  1. Resolved — personality: conversational (overriding the terse-professional proposal), fiduciary framing and grounding rules intact.
  2. Resolved — disclaimer footer: first assistant message per thread, as proposed.
  3. Resolved — per-day chat budget: $2/day default (user_settings.chat_budget_usd_daily), superseding the $1/day proposal; confirmed as the doc-05 budget too.
  4. Resolved — CSV paste-to-import: deferred; imports stay in Settings → Accounts.