The tool surface
financialTools is an array of Anthropic Tool definitions
exported from src/lib/chat/tools.ts, lines 7 to 390. It holds twenty
definitions: twelve financial, eight payroll and compensation. Each has a matching
…Input interface, and the twenty are unioned into an exported
ToolInput type.
Four structural invariants are checked across the whole array rather than tool by tool:
every name is unique, every description exceeds ten characters after trimming, every
input_schema.type is 'object', and every schema defines a
properties object. A tool added without a real description fails the suite.
__tests__/lib/chat/tools.test.ts line 12 reads
expect(financialTools).toHaveLength(19), under a test named
“should have exactly 19 tools defined (12 financial + 7 payroll)”. The array
now has twenty entries: get_team_compensation was added without the
assertion being updated. On the code as committed this test fails.
It is the clearest instance on these sheets of why a passing test is evidence about the
past rather than the present, and it is why the tool tables below are read from
tools.ts rather than from the suite.
$ npm test -- __tests__/lib/chat/tools.test.ts > gogenticcfo@0.1.0 test > jest __tests__/lib/chat/tools.test.ts FAIL __tests__/lib/chat/tools.test.ts Financial Tools Tool Definitions ✓ should export an array of tools ✗ should have exactly 19 tools defined (12 financial + 7 payroll) ● Financial Tools › Tool Definitions › should have exactly 19 tools defined expect(received).toHaveLength(expected) Expected length: 19 Received length: 20 Received array: [ … ] 11 | it('should have exactly 19 tools defined (12 financial + 7 payroll)', () => { > 12 | expect(financialTools).toHaveLength(19); | ^
Reconstructed from the committed sources: the assertion at tools.test.ts:12 and the twenty name: entries in tools.ts. The array dump is elided. The remaining 77 assertions in the file address individual tools and are unaffected.
← Scroll to see the full diagram →
tool-handlers.test.ts invokes any of them, which matches the open item at
TASKS.md 10.6. The calculate tool is the only one that touches
neither the database nor the metrics module.
Schedule of tools
Parameters and their enumerations, read directly from the exported schemas. The required column is significant: only four of the twenty tools declare a required parameter at all, so the model can call most of them bare and refine afterwards.
| Tool | Parameters | Required |
|---|---|---|
query_transactions |
search, category, startDate, endDate, direction ∈ {inflow, outflow}, limit, aggregate, groupBy ∈ {category, counterparty, day, month} |
none |
get_metrics |
metric ∈ {cash_balance, burn_rate, runway, net_change, expenses_by_category, cash_flow_by_month}, days, months |
metric |
get_monthly_metrics |
year, month, metric ∈ {expenses_by_category, revenue_by_category, all} |
year, month |
get_trends |
metric ∈ {revenue, expenses, net_change, burn_rate}, comparison ∈ {mom, yoy, custom}, customDays |
metric, comparison |
get_vendors |
limit, days, direction ∈ {inflow, outflow} |
none |
get_recent_activity |
period ∈ {today, yesterday, this_week, last_7_days, last_30_days} |
none |
get_anomalies |
days, threshold |
none |
get_categories |
includeTransactionCounts |
none |
calculate |
expression — the description is asserted to contain the word “arithmetic” |
expression |
get_accounts | — | none |
get_sync_status | — | none |
get_financial_summary | — | none |
Source: src/lib/chat/tools.ts. Only four of the twenty tools declare a required array: get_metrics, calculate, get_trends and get_monthly_metrics.
| Tool | Parameters | Required |
|---|---|---|
get_payroll_summary | startDate, endDate | none |
get_employees | activeOnly (default true), includeLatestPayroll (default false) | none |
get_payroll_run | runId (uuid), payDate | none |
get_employee_payroll | employeeId, employeeName, startDate, endDate | none |
get_payroll_trends | months (default 12) | none |
get_payroll_by_department | startDate, endDate | none |
get_payroll_taxes | startDate, endDate | none |
get_team_compensation | months (default 12, null for year to date) | none |
The twentieth tool, get_team_compensation, is the newest. Its description spans both payroll sources: W2 employees from the Gusto-originated journal entries and 1099 contractors from QuickBooks, split across five employment classifications. See Sheet 4.
Why get_monthly_metrics exists at all. The other metric tools take
a rolling days window. That cannot express “what did we spend in
March”. TASKS.md 9.1 records the addition of a calendar-month tool
alongside a Tool Selection Guide section in the system prompt telling the
model which to reach for — and the tool's description is asserted to contain the
literal phrase “calendar month” so the distinction survives editing.
The handler layer
handleToolCall(name, input, userId) is the only entry point. It returns a
JSON string in every case, including failure — a test iterates all eleven
implemented handlers plus one nonexistent name and asserts
JSON.parse never throws on any of them. Errors are values, not exceptions.
handleToolCall('unknown_tool', {}, userId)
→ '{"error":"Unknown tool: unknown_tool"}'
// a rejected database call is caught and surfaced the same way
// with the underlying message preserved verbatim:
→ { error: 'Database error' }
→ { error: 'Aggregation failed' }
→ { error: 'Settings query failed' }
Bounds on query_transactions
| Input | Reaches getTransactions as |
|---|---|
| {} | ({}, 1, 10) — page 1, ten rows |
| { limit: 100 } | (…, 1, 50) — clamped to fifty |
| { category: 'Software' } | { categoryId: 'cat-1' } — name resolved to id |
| { category: 'software' } | the same — resolution is case-insensitive |
| { startDate: '2024-01-01' } | a real Date, parsed by the handler |
| { direction: 'outflow' } | passed through unchanged |
The fifty-row ceiling is why TASKS.md 9.1 added both an aggregate mode — SQL aggregation with no limit — and “a warning when hitting the 50 transaction limit”, so a truncated answer announces itself rather than silently under-reporting.
Response vocabulary
Handlers return prose-ready values alongside raw ones, so the model does not have to format currency itself and cannot get it wrong.
| Tool | Returns |
|---|---|
get_metrics — cash_balance |
{ metric, value, formatted }. Cents in, dollars in value, and formatted carries the grouped currency string |
— burn_rate |
Adds a description asserted to contain “3 months” — the averaging window is stated in the payload, not left implicit |
— runway |
formatted is '12.5 months'; an infinite runway formats as the literal 'Unlimited' rather than Infinity, which would not survive JSON |
— expenses_by_category |
categories[] with amount in dollars and percentage pre-rendered as '50.0%' |
— cash_flow_by_month |
months[] of { inflows, outflows, net }, all converted |
get_accounts |
{ accounts[], totalCount }. A null balance yields balance: null and formattedBalance: 'N/A' — not $0.00, which would be a lie |
get_vendors |
Defaults days: 30, direction: 'all', both echoed back. A null counterparty becomes 'Unknown', a null category 'Uncategorized' |
get_trends |
{ current, previous, change } with named periods — 'Last 30 days' against 'Prior 30 days' for mom, 365 for yoy, and customDays for custom. change.percent is signed: '+25.0%', '-20.0%' |
get_sync_status |
{ lastSyncAt, syncEnabled, hasApiKey, accountCount, transactionCount }. hasApiKey is a boolean derived from the encrypted column — the ciphertext itself is never returned |
get_financial_summary |
{ cashBalance, burnRate, runway, recentTransactions, topExpenses } — five queries behind one call, for opening a conversation |
get_recent_activity |
{ period, transactionsByDate, summary }, grouped by day |
get_anomalies |
{ days, threshold, anomalies, largestTransactions, newVendors, summary } |
Cache partitioning
Tool results are cached in a process-local Map, keyed by user, for sixty
seconds. Four tests exist for this one property, under a describe block named
“Multi-tenant cache isolation”.
const cache = new Map<string, { data: string; expires: number }>();
const CACHE_TTL = 60 * 1000; // 1 minute
// every lookup is namespaced by the caller
const cacheKey = `${userId}:${key}`;
There is no size cap and no background eviction — an expired entry is deleted lazily, on the next read that misses it. Because the map lives in module scope, each serverless instance keeps its own copy and a warm instance is the only place a hit can occur.
What is cached, and what is not
| Tool | Key, after the userId: prefix |
|---|---|
get_metrics | metrics:${metric}:${days} |
get_accounts | accounts |
get_vendors | vendors:${limit}:${days}:${direction} |
get_trends | trends:${metric}:${comparison}:${customDays} |
get_financial_summary | financial_summary |
get_payroll_summary | payroll_summary:${startDate}:${endDate} |
get_employees | employees:${activeOnly}:${includeLatestPayroll} |
get_payroll_trends | payroll_trends:${months} |
get_payroll_by_department | payroll_dept:${startDate}:${endDate} |
get_payroll_taxes | payroll_taxes:${startDate}:${endDate} |
get_team_compensation | team_compensation:${months} |
| Not cached | query_transactions, get_categories, get_sync_status, get_recent_activity, get_anomalies, get_monthly_metrics, get_payroll_run, get_employee_payroll, calculate |
One key is incomplete. get_metrics composes its key from
metric and days, but the cash_flow_by_month branch
is parameterised by months, which never reaches the key. Within a
sixty-second window, a request for three months and a request for twelve months share a
cache entry, and the second caller receives the first caller's series. The window is
short and the tools are usually called once per turn, which is presumably why it has not
surfaced.
The test design is worth describing because it proves the negative rather than the positive. Each test primes the cache for one user, then arranges the database mock to return the wrong user's data on the next call. If the cache leaked, the second user would receive the first user's figures; if it were absent, the first user's repeat call would pick up the deliberately mismatched fixture. Both directions are asserted.
// 1. populate user A's cache
mockDb._setResults([userAAccounts]);
await handleToolCall('get_accounts', {}, TEST_USER_ID); // A's data
// 2. user B must NOT be served from A's entry
mockDb._setResults([userBAccounts]);
await handleToolCall('get_accounts', {}, OTHER_USER_ID); // B's data
// 3. A repeats — the mock is loaded with B's data, so a cache
// miss here would return the wrong figures. It returns A's.
mockDb._setResults([userBAccounts]);
await handleToolCall('get_accounts', {}, TEST_USER_ID); // still A's data
| Tool | What is cached |
|---|---|
get_accounts | The account list and balances |
get_metrics | Each metric independently — a repeat call for cash_balance does not re-query |
get_vendors | The aggregated vendor rollup |
get_financial_summary | The composite of five underlying calls |
clearCache() is exported alongside handleToolCall and safeEvaluate, and is called in beforeEach throughout the suite so that no test inherits another's cached state.
The application serves one company, so two users see the same underlying ledger. The partitioning is nonetheless real, and it is the mechanism that would be load-bearing if the product ever grew a second tenant. As built, its practical effect is that one user's cached figures cannot go stale differently from another's, and that a cache entry cannot outlive the session that created it.
The arithmetic sandbox
Language models are unreliable at arithmetic, so the agent is given a calculator. It could
not be eval, so safeEvaluate is a hand-written expression parser
with 46 tests of its own.
| Feature | Examples, with asserted results |
|---|---|
| Four operations | 1 + 2 → 3 · 5 - 3 → 2 · 3 * 4 → 12 · 10 / 2 → 5 |
| Decimals, negatives | 1.5 + 2.5 → 4 · -10 * 2 → −20 |
| Precedence | 2 + 3 * 4 → 14 · 10 - 2 * 3 → 4 |
| Parentheses, nested | (2 + 3) * 4 → 20 · ((2 + 3) * 4) + 1 → 21 |
| Unary functions | sqrt(16) → 4 · abs(-5) → 5 · floor(1.9) → 1 · ceil(1.1) → 2 |
| Rounding | round(1.5) → 2 · round(1.567, 2) → 1.57 — the precision argument is optional |
| Variadic | min(10, 5, 8) → 5 · max(10, 5, 8) → 10 · pow(2, 3) → 8 |
| Case | SQRT(16) → 4 · Abs(-5) → 5 |
| Percent literal | 50% → 0.5 · 100 * 20% → 20 |
| Scientific | 1e3 → 1000 · 1.5e2 → 150 |
| Composition | sqrt(4 + 12) → 4 · sqrt(16) + abs(-5) → 9 |
| Whitespace | 1 + 2 → 3 · 1+2*3 → 7 — both extremes tolerated |
What it refuses
| Expression | Message |
|---|---|
| 10 / 0 | Division by zero |
| 10 + x | Invalid characters |
| (10 + 5 | Mismatched parentheses |
| unknown(5) | Invalid characters — an unlisted function name is not a function, it is illegal input |
| sqrt(1, 2) | sqrt requires 1 argument |
| pow(1) | pow requires 2 arguments |
| min(1) | min requires at least 2 arguments |
| eval("1+1") | throws |
| require("fs") | throws |
| process.exit() | throws |
The last three sit in a describe block titled “should reject potentially dangerous input”. They are caught by the same character allow-list that rejects x, which is the point: the parser has no escape hatch to close, because it never had one.
Division by zero throws rather than returning Infinity. In a financial
context that is the right call — a runway of Infinity is meaningful and
is handled explicitly by get_metrics, but an accidental infinity
arriving out of a calculator would be indistinguishable from it.
The stream
POST /api/chat answers with a stream of newline-delimited
data: frames. Five event types travel over it, and the client is written to
survive anything else that arrives.
| Setting | Value |
|---|---|
| Model | claude-sonnet-4-5-20250929 |
max_tokens | 4,096 |
stream | true, with the full financialTools array attached |
API_TIMEOUT_MS | 30,000 |
MAX_TOOL_ITERATIONS | 10 — the agent loop cannot recurse indefinitely |
MAX_REQUEST_SIZE_BYTES | 1,048,576. A larger Content-Length is refused with 413 { error: 'Request too large' } before the body is read |
The same model identifier appears in exactly one other place, src/lib/ai/categorize.ts, also at max_tokens: 4096. Those two call sites are the entire model surface of the application.
Failures arrive with a 200. Once the stream has opened the status is already
committed, so a mid-flight error is delivered as a type: 'error' frame
carrying one of five codes — TIMEOUT, RATE_LIMIT,
AUTH_ERROR, SERVICE_OVERLOADED, UNKNOWN_ERROR.
Only failures detected before the stream opens produce a real status: 401 for a missing
session, 413 for an oversized body, 400 for a schema violation.
← Scroll to see the full diagram →
tool_start / tool_end
pair exists purely so the interface can say what the agent is doing while it does it;
neither carries a result. Persistence runs alongside the stream rather than after it, so
a slow write cannot delay a token.
Frame handling
- Framing
- Lines of the form
data: <json>\n— a single newline, not the blank-line terminator of canonical SSE. The reader splits on newlines and inspects the prefix. - Unknown prefixes
- A line beginning
event: messageis skipped silently. The client reads onlydata:. - Empty payload
data:with nothing after it is skipped.- Malformed JSON
- Logged via
console.warn; the loop continues and later valid frames are still processed. One bad frame does not abort a response. - An
errorframe - Caught by an inner handler and warned, deliberately not promoted to the context's
errorstate — a mid-stream tool failure does not blank the answer already rendered.
Request validation
chatRequestSchema is exported separately from the route handler so it can be
tested in isolation, and it is strict in both directions.
| Field | Bound | Message |
|---|---|---|
messages | min 1 | At least one message is required |
messages | max 100 | Too many messages — 100 passes, 101 fails |
role | enum | Exactly 'user' or 'assistant'. 'system' is rejected, so a client cannot inject a system turn |
content | min 1 | Message content cannot be empty |
content | max 32,000 | Message content too long — exactly 32,000 passes |
Whitespace is not trimmed: ' ' satisfies the minimum-length rule. The empty-message guard that actually matters lives in the client, which refuses to call the endpoint at all for an empty or whitespace-only input.
Conversation state
Two implementations coexist. useChat is a self-contained hook that talks only
to /api/chat, handles only text frames and explicitly ignores
the rest. ChatContext is the one the application mounts: it adds persistence,
conversation switching, tool status and cancellation. Both are tested; only the context is
reachable from the running app. message-list.tsx imports the hook's
Message type and nothing else from it.
messages: Message[] // { id, dbId?, role, content, createdAt }
isLoading: boolean
error: string | null
isOpen: boolean
toolStatus: { isActive: boolean; toolName: string | null }
conversationId: string | null
conversations: Conversation[]
isLoadingConversations: boolean
setIsOpen(open) sendMessage(content) clearMessages()
cancelRequest() loadConversations() selectConversation(id)
createNewConversation() deleteConversation(id)
Eight values and eight functions. useChatContext() throws 'useChatContext must be used within a ChatProvider' outside the provider — the provider is mounted once, in src/app/(dashboard)/layout.tsx, wrapping the entire dashboard.
| Capability | useChat | ChatContext |
|---|---|---|
Streams from /api/chat | yes | yes |
| Assistant placeholder appended on send | yes | yes |
| Refuses empty input without a request | yes | yes |
| Tool status | no | { isActive, toolName } |
| Abort in flight | no | AbortController in a ref |
| Persists to the database | no | background POST and PATCH |
| On failure the reply reads | Sorry, I encountered an error. Please try again. | sets error; the rendered text is left alone |
Three properties of the context worth naming
- No stale history
- The message array is mirrored into a ref so that the request body always carries the current history rather than the array captured when the handler was created. The test is exact: the first call sends one message, the second sends three — the original question, the assistant's reply, and the new question.
- Aborts are not errors
-
Sending a second message while the first is still streaming aborts the first. A rejection
whose
nameis'AbortError'is asserted not to set the error state.clearMessages()aborts too. - The finally block
-
After every send — success, abrupt stream end, network rejection, or a non-OK
response —
isLoadingreturns tofalseandtoolStatusresets to{ isActive: false, toolName: null }. Four separate tests assert this, one per exit path, so the interface cannot be left showing a spinner.
Retry policy on the client
A fetchWithRetry(url, options, maxRetries = 2) wrapper sits in front of the
chat call. A 4xx returns immediately; a 5xx or a network failure is retried with
exponential backoff of 1000 × 2attempt milliseconds —
one second, then two. Client faults surface at once as
Chat request failed.
Two scroll mechanisms, one list. use-smart-scroll.ts watches a
container's scroll position and auto-scrolls only when the user is within 50 px of
the bottom — the classic “don't yank the view while someone is reading
back” guard, and the behaviour TASKS.md 5.4 records as “smart
scroll”. ChatPanel attaches its ref to an outer
overflow-y-auto div, while MessageList independently calls
scrollIntoView({ behavior: 'smooth' }) on every message change from inside a
Radix ScrollArea, which scrolls its own viewport. The hook is therefore
watching an element that is not the one doing the scrolling.
Rendering a partial answer
Streamed markdown is, by definition, usually invalid — a code fence may be open, a
link half-written, a bold run unterminated. sanitizeStreamingMarkdown repairs
the string before each render so the view never flickers through a broken parse.
| Unclosed | Repair |
|---|---|
| Code fence | An odd count of ``` appends \n``` |
| Inline code | An odd count of backticks outside fenced blocks appends one. Backticks inside a closed fence are excluded from the count |
| Link | An open ( after a link text appends ); an open [ with no ( appends ] |
| Bold | An odd count of ** appends **. Markers inside inline code are excluded |
| Italic | An odd count of single *, after discounting ** pairs and code spans, appends * |
A false positive, deliberately preserved. One test asserts that
Price: $5 * 3 = $15 is rewritten to Price: $5 * 3 = $15*
— a bare multiplication sign is indistinguishable from an unclosed italic to a
counting heuristic. The behaviour is documented in the test rather than worked around,
which is the honest choice: any fix would require actually parsing the markdown, and the
artefact is transient. It disappears the moment the stream closes and the final,
well-formed text is rendered.
What the panel says while it works
ToolStatusIndicator maps a tool name to a phrase. It is a
role="status" region with aria-live="polite", so a screen reader
announces the change without interrupting.
| Tool | Displayed |
|---|---|
query_transactions | Searching transactions… |
get_metrics | Calculating metrics… |
calculate | Running calculation… |
get_accounts | Fetching accounts… |
get_categories | Loading categories… |
get_vendors | Analyzing vendors… |
get_trends | Comparing trends… |
get_sync_status | Checking sync status… |
anything else, or null | Working… |
Eight of the twenty tools have a bespoke phrase. The remaining twelve — including all eight payroll tools — fall through to Working…. The map is a module constant, TOOL_DISPLAY_CONFIG, in src/components/chat/tool-status-indicator.tsx.
Opening prompts
An empty conversation offers four fixed suggestions under the heading
Try asking:. They map onto the four headline metrics the dashboard already
computes, which is what makes them answerable in a single tool call.
| # | Prompt | Likely tool |
|---|---|---|
| 1 | What's my current cash balance? | get_metrics, cash_balance |
| 2 | How much did we spend last month? | get_monthly_metrics |
| 3 | What are our biggest expenses? | get_metrics, expenses_by_category |
| 4 | What's our runway? | get_metrics, runway |
Prompt strings are asserted verbatim in __tests__/components/chat/suggested-prompts.test.tsx. The tool mapping in the right-hand column is inference, not assertion — no test connects a prompt to a tool.