AI Pipeline

Multi-agent architecture · ← Hub

Stage 1 — Input

User Message

User sends natural language query via mobile chat UI. Example: "How much did I spend on dining this month?"

Stage 2 — Classification (DISABLED)

Intent Classifier (Haiku 4.5)

Currently turned OFF — chats_controller.ts sets const intentPromise = Promise.resolve('unknown'). A per-message Haiku call competed with the chat stream for Bedrock quota and triggered throttling, so the stage is stubbed out (re-enable once AWS quota is raised). When live, it would classify query intent on AWS Bedrock; today every turn is routed to the Coach with all 8 tools regardless of intent.

disabled (was <500ms target)
Stage 3 — Agent

Tide Coach Agent (Sonnet 4.6)

Primary AI financial coach. Has all 8 tools available every turn (intent routing is disabled — see Stage 2) and lets the model pick which to call. Generates personalized financial advice with full conversation context.

First token <2s
Stage 4 — Tool Execution

Backend API (Read-Only DB)

Agent's tool calls execute against PostgreSQL via Backend API. Uses dedicated read-only connection. Bouncer policies enforced.

30-200ms per query
Stage 5 — Streaming

SSE Response via XMLHttpRequest

Tide Coach generates natural language response streamed via SSE using XMLHttpRequest (not fetch — RN's fetch lacks response.body). Tokens appear in real-time. Partial responses saved even when streaming errors occur.

SSE via XHR
Stage 6 — Persistence (Async + Fallback)

Chat Persistence + Coaching History

Both user and assistant messages persisted with intent, model, and toolsUsed metadata. toolsUsed jsonb column uses JSON.stringify prepare/consume serializers. BullMQ primary with synchronous DB fallback if dispatch fails. Error path: partial AI responses saved even on streaming errors. Content AES-256 encrypted.

BullMQ async · sync fallback · 30d retention

Tide Coach Tools (8)

What the Coach can look up. Click a tool for its reference: what it is for, its inputs (read from the code), its logic step by step, its output, and its known limits. ⚠ marks a tool with an open eval finding.

Tool reference

TOOL 1

query_user_profile

Who the user is: name and how long they have used Tide.

When the Coach uses it
Personalising a greeting, or questions about the account itself.
Inputs

No inputs. The tool always works on the signed-in user.

Logic, step by step
  1. Loads the user and profile on the read-only connection (tool_executor.ts queryUserProfile).
  2. Computes account age in days from users.created_at.
Output
  • name, accountCreated, accountAgeDays
Known limits
  • No phone, address or other contact PII by design.
TOOL 2

query_bank_accounts

Every linked, active account with its balances.

When the Coach uses it
"What is my balance", net-worth style questions, or anything naming a specific account.
Inputs

No inputs. The tool always works on the signed-in user.

Logic, step by step
  1. Reads bank_accounts where is_active, with the owning bank_connection (queryBankAccounts).
  2. Returns the official account name when the bank provides one, otherwise the display name.
Output
  • per account: id, name, accountType (depository / credit / loan), accountSubtype, institutionName, currentBalance, availableBalance, currency
Known limits
  • For credit and loan accounts currentBalance is what is OWED, not cash. The Coach has to read accountType to tell them apart.
  • Balances are as of the last Plaid sync, not live.
  • No asset values (home, car), so it cannot support a true net-worth figure.
TOOL 3

query_transactions

Raw transaction lookup with filters.

When the Coach uses it
A specific merchant, date range, account or amount ("How much at Amazon lately?").
Inputs
InputTypeMeaning
bankAccountIdnumberFilter to a specific bank account ID (from query_bank_accounts). Use this when the user asks about a specific account or bank.
startDatestringISO date YYYY-MM-DD
endDatestringISO date YYYY-MM-DD
categoryIdnumber
merchantNamestring
minAmountnumber
maxAmountnumber
transactionTypedebit | credit
limitnumberMax results, default 50
Logic, step by step
  1. Reads transactions for the user, newest first, with merchant, category and account (queryTransactions).
  2. Hides a pending row once its posted twin exists (excludeSupersededPending, the same rule as the app's transaction list), so each real charge appears once (5c, 2026-10-04).
  3. Applies whichever filters are given: account id, start/end date, category id, merchant name (case-insensitive contains), min/max amount, debit/credit.
  4. Returns at most limit rows: default 50, never more than 100.
Output
  • per transaction: id, amount, description, transactionDate, transactionType, isPending (true = not posted yet, never a duplicate), merchant, category, accountName, institution
Known limits
  • Capped at 50 rows by default (100 max) with NO total count, so a "total" summed from the list can silently miss older rows.
  • Pending rows with no posted twin yet are still listed (isPending=true); they are real charges awaiting settlement.
  • Filtering by category needs a numeric categoryId, and no tool lists category ids.
TOOL 4

query_bills_predictions

Everything due soon: recurring bills, credit-card payments, and subscriptions billed to cards.

When the Coach uses it
"What bills are coming up?", "When is my mortgage due?", "Any subscriptions renewing soon?"
Inputs
InputTypeMeaning
daysAheadnumberHow many days ahead to look, default 30
unpaidOnly ignored by the codebooleanOnly return unpaid bills, default true
Logic, step by step
  1. Window: today to today + daysAhead (default 30), in the user's timezone (queryBillsPredictions).
  2. bills[]: active bill/subscription recurring_streams paid from checking (isMoneyMathEligible), expanded to every occurrence inside the window by cadence (occurrencesWithin).
  3. cardPayments[]: each card's statement amount due inside the window, from computeSafeToSpend's ccObligations — the same ladder Home's Recurring Expenses merges in (5c fixes finding #1).
  4. subscriptionsBilledToCards[]: subscriptions charged to a card. Real subscriptions, but already inside that card's payment, so excluded from totalDue.
  5. totalDue = bills + cardPayments.
Output
  • window {from, to, timezone}
  • bills[] {id, predictedAmount, predictedDate, confidence (0–1), merchant, recurrencePattern, streamType}
  • cardPayments[] {card, amountDue, dueDate, basis, minimumPayment}
  • subscriptionsBilledToCards[] (same shape as bills), totalDue, note
Must match
Home Recurring Expenses (checking bills + card payments). Both read the same code.
Known limits
  • unpaidOnly is declared but ignored.
  • A subscription whose predicted date has already passed is not listed (it may have lapsed); recurring_charges flags those as likelyEnded.
TOOL 5

query_payday_predictions

When the next paycheck lands and how much, plus other expected income.

When the Coach uses it
"When do I get paid?", "Will I make it to payday?"
Inputs
InputTypeMeaning
daysAheadnumberHow many days ahead to look, default 30
Logic, step by step
  1. Resolves the authoritative next payday with resolvePayday (the same helper Home uses): the user's payday setting wins on cadence, the income stream wins on amount.
  2. paydays[]: every payday in the window, walking the same schedule forward with resolvePayday (5e, 2026-10-04: "what comes in next month?" saw one deposit).
  3. upcoming[]: every occurrence of each active income stream in the window (occurrencesWithin), excluding transfer-ins, refunds and card "payment received" credits; partOfPaycheck marks user-confirmed slices already inside a payday amount.
Output
  • resolved: nextPayday, predictedAmount, frequency, source, confidence, band (low/medium/high), isFallback, amountBasis, daysUntilPayday, isPaydayToday
  • paydays[]: date, predictedAmount, amountBasis
  • upcoming[]: id, source, predictedAmount, predictedDate, confidence, frequency, partOfPaycheck
  • window {from, to, timezone}, note
Must match
Home payday chip. Both read the same code.
Known limits
  • A low-confidence or fallback payday is an ESTIMATE. The Coach must not state it as fact (red line 10).
TOOL 6

calculate_safe_to_spend

How much the user can spend: the same Safe to Spend engine that powers Home.

When the Coach uses it
Affordability ("Can I afford…?"), "How much can I spend this week?", "Will I make it to payday?"
Inputs

No inputs. The tool always works on the signed-in user.

Logic, step by step
  1. Calls computeSafeToSpend (safe_to_spend_service.ts) in the user's timezone, the single engine shared with GET /predictions/safe-to-spend.
  2. The engine projects the checking balance day by day: L(d) = cash + income landing on or before d − obligations on or before d. Obligations are bills, loans, card payments due, essentials and estimated everyday "habits".
  3. Horizon = the latest of: the day before the runway payday, month end, week end (v5.6). The runway payday is the next payday, except ON payday, when it is the following one, so payday never collapses the runway to one day.
  4. Daily pace = the lowest L(d) ÷ (d+1) across the horizon, so spending at that pace never dips below zero on any day.
  5. Each window (today, this week, until payday, this month) = pace × the days in that window. If the pace is negative (a shortfall), every window equals the deficit instead.
  6. projectSafeToSpendForCoach reshapes the result so every number Home shows is reachable from one tool call.
Output
  • today / thisWeek / untilPayday / thisMonth: amount, daysInWindow, perDayInWindow, moneyOut {billsUtilsSubs, loans, plannedSavings, ccDue, essentials, habits, total}, bills[], income[], cardPaymentsHeldBack[] and cardPaymentsAfterWindow[] {card, amount, dueDate} (round-2 fix: computed by splitCardPayments so the Coach quotes which payments a window already covers instead of comparing dates)
  • dailyBudget, daysUntilPayday, isPaydayToday, nextPayday, runwayPayday, spendableBalance
  • floor {date, value, bindingBill}, shortfall {isShortfall, shortfallAmount, earliestShortfallDate}
  • ccObligations[] per card {amountDue, basis, dueDate, minimumPayment, purchaseApr, totalBalance}, ccNextCycleWarning, creditCardDebt (display only)
  • essentials, typicalDiscretionary, accountsIncluded, setAsideAccounts, loansExcluded, lastSyncedAt
Must match
Home Safe to Spend card. Both read the same code.
Known limits
  • A card payment is subtracted only once its due date falls inside the window, so "already accounted for" is wrong for a window that ends before the due date. On payday the runway reaches the day before the next paycheck; statements due after that are left to that paycheck (owner decision 5f).
  • creditCardDebt is the full revolving balance for display. It is NOT subtracted; only the statement amount due is.
TOOL 7

get_spending_by_category

True spending by category: the same numbers as the Spending tab donut.

When the Coach uses it
"What did I spend on food this month?", "Where did my money go?"
Inputs
InputTypeMeaning
timeframeday | week | monthSpending-tab window in the user's timezone: day = today, week = last 7 days rolling, month = current calendar month to date. Default month. Ignored when startDate/endDate are given.
startDatestringISO date YYYY-MM-DD — custom range start (overrides timeframe)
endDatestringISO date YYYY-MM-DD — custom range end
Logic, step by step
  1. Resolves the window in the user's timezone: today, last 7 days rolling, or month to date (default). A custom start/end overrides it; an end without a start looks back 30 days (getSpendingByCategory).
  2. Fetches posted rows only (pending excluded) with the Spending tab's shared SQL (fetchSpendingDetailRows).
  3. projectSpendingForCoach partitions income, transfers and card-bill payments out of spending, nets refunds per category, and sums positive categories into totalSpent.
Output
  • window {timeframe, startDate, endDate, timezone}, totalSpent, transactionCount
  • categories[] {category, total, pctOfTotal, transactionCount, subcategories[]}
  • excluded {income, transfersIn, transfersOut, ccPayments}, uncategorized {total, transactionCount}
Must match
Spending tab donut. Both read the same code.
Known limits
  • Month to date is a partial month. Comparing it with a full previous month needs saying so.
  • Finding #5. A NEGATIVE uncategorized bucket is money coming in, not refunds and not "hidden spending". Before 2026-10-03 every paycheck and Zelle/Venmo transfer landed there because Plaid v2 codes were missing from Tide's categories, so income read $0. That root cause is fixed; any rows still uncategorized carry the deliberately unmapped OTHER_OTHER code.
TOOL 8

get_financial_insights

Four analyses in one tool: monthly trend, largest expenses, recurring charges, anomalies.

When the Coach uses it
Trends ("Am I spending more than usual?"), biggest purchases, "What am I subscribed to?", "Anything weird?"
Inputs
InputTypeMeaning
insightTypespending_trends | largest_expenses | recurring_charges | anomalies
monthsnumberHow many months of data to analyze, default 3
Logic, step by step
  1. insightType picks the analysis (default spending_trends). months is capped at 12, default 3 (getFinancialInsights).
  2. spending_trends: the same true-spending monthly totals as the Home chart, calendar months in the user's timezone.
  3. The other three read the same rows: transactions in the window with superseded pendings hidden, shaped for the pure rules in coach_insights.ts (5c, 2026-10-04).
  4. largest_expenses: the 10 biggest real expenses (isExpense: no transfers, income or credit-card payments).
  5. recurring_charges: billsAndSubscriptions[] and income[] (billedToCard, likelyEnded = expected charge over 7 days overdue) plus repeatedTransfers[] — the same transfer amount to the same counterparty 2+ times, grouped by normalized description because the merchant normalizer mints a new merchant per transfer row.
  6. anomalies: candidates from the last 30 days judged against the window's history. Non-habitual charges above category mean + 2σ (unusually_large); a habitual merchant (3+ charges) only when over 3× its median AND over 1.5× its previous highest (large_for_this_merchant); more charges at one merchant within two days than usual — 3+, or double its busiest earlier two days for a habitual merchant (rapid_repeat); two identical posted charges at one merchant on one day (identical_same_day, to ask about).
Output
  • spending_trends: months[] {month, total, transactionCount}
  • largest_expenses: window, excludes, transactions[] {amount, description, date, merchant, category}
  • anomalies: window {historyFrom, candidatesFrom}, transactions[] {amount, description, date, merchant, category, reason, detail}, note
  • recurring_charges: window, billsAndSubscriptions[], income[], repeatedTransfers[] {direction, counterparty, amount, count, dates, typicalGapDays}, notes
Must match
Home spending chart (spending_trends only). Both read the same code.
Known limits
  • Anomaly rules are heuristics, not fraud detection: none of these flags means fraud on its own.
  • Repeated transfers need the same amount to the cent; a transfer that varies in amount is not grouped.

Agent Memory Model (Tool-Based Context)

The agent pulls context on demand via tools — no pre-loaded financial profile in prompts. Scales with data growth, token-efficient.

TIER 1

Session Context

Current chat thread built into the conversation. Resets between sessions. Provides immediate conversational continuity.

TIER 2

Financial Data

Accounts, transactions, predictions, spending. Agent pulls on demand via tools 1-8. Data lives in Postgres, fetched only when relevant.

TIER 3

Coaching History — removed

Removed 2026-10-04 (round-1 failure type 14). Nothing wrote coaching history, so the tool always returned empty while the prompt told the Coach to follow up on past advice — an invitation to invent it. Tide keeps no memory across chats, and the Coach now says so.

Model Specifications

Claude Haiku 4.5

RoleIntent Classifier (DISABLED)
Latency<500ms (stubbed: Promise.resolve('unknown'))
PurposeFast query routing
PlatformAWS Bedrock
Cost~$2/mo at 1K req

Claude Sonnet 4.6

RoleTide Coach Agent
LatencyFirst token <2s
PurposeFinancial coaching + tools
PlatformAWS Bedrock
Tools8 tools available
TokensRead from each stream's metadata event, summed across the tool loop into ai_traces (cached prompt tokens included)
TracingOne trace per turn to Arize AX when enabled: AGENT → LLM per loop step + TOOL per call (with its output), masked; content only for allowlisted users. Feeds the real-data evals.

Claude Haiku 4.5 — Merchant Classifier

RolePlaid-sync merchant classification
TriggerMerchants in ambiguous Plaid codes (GENERAL_SERVICES_OTHER, etc.)
PurposeFill the Software & Tech / Subscriptions gap Plaid's PFC taxonomy leaves
Batch size25 merchants per call
Cachemerchants.ai_category_id (once per unique merchant)
PlatformAWS Bedrock
Cost~pennies per user lifetime

Plaid Sync — Category Resolution Priority

Canonical spec in Transaction Sync Flow (where it executes at sync time). Mirrored here for the AI-first view since Tier 2 runs Claude Haiku.

TIER 1

User Merchant Rule

merchant_category_overrides row — written when the user recategorizes a transaction and picks "Always for <merchant>" (PATCH /transactions/:id with applyToMerchant: true). categorySource='rule'. Direct per-transaction edits set categorySource='user' on the row itself, which sync never clobbers (the guard above all tiers).

TIER 2

Merchant AI Cache

merchants.ai_category_id — set by Claude Haiku on a prior sync when Plaid's code was ambiguous. categorySource='ai'. One Haiku call per unique merchant, reused forever.

TIER 3

Plaid Detailed Code

Plaid's native PFC classification, matched to a Tide category by exact code. Used when there is no user override and no AI cache. categorySource='plaid'. Handles confident cases (coffee, gas, groceries) with no AI cost. Tide seeds both Plaid taxonomies (v1 and v2) so every code Plaid sends has a category. An INCOME_* code on a credit card resolves to a card payment, never income. An unmatched code is logged at sync time instead of silently saving the transaction uncategorised (the 2026-10-03 bug that hid every paycheck).