Gogentic Finance · internal engineering record

Sheet 5 of 6

Advisory Desk

The agent does not see the database. It sees twenty tools, each returning a JSON string in dollars, and every one of them scoped to the user who asked. This sheet is the specification of that boundary.

Tool definitions
20
Stream events
5
Cache TTL
60 s
Tool loop cap
10
Message cap
100
Content cap
32,000
1

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.

Note — the length assertion is stale

__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.

Terminal — the assertion against the committed array
$ 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.

Exhibit 10
Agent tool topology and backing data sources Twenty tools grouped into read, analyse, payroll and compute families, all passing through a single handleToolCall dispatcher with a per-user cache, and resolving against four backing layers: the metrics module, the transaction query module, direct Drizzle aggregation over the ledger, and pure in-process computation. 20 TOOL DEFINITIONS DISPATCH BACKING LAYER READ query_transactions get_accounts get_categories get_vendors get_sync_status get_recent_activity ANALYSE get_metrics get_monthly_metrics get_trends get_financial_summary get_anomalies 5 tools PAYROLL & COMPENSATION get_payroll_summary get_employees get_payroll_run get_employee_payroll get_payroll_trends get_payroll_by_department get_payroll_taxes get_team_compensation 8 tools — handlers exist, none tested COMPUTE calculate no I/O handleToolCall(   name,   input,   userId) always returns a JSON string, never throws unknown name → { error: 'Unknown tool' } cache `${userId}:${key}` TTL 60,000 ms clearCache() exported src/lib/metrics getCashBalance, getMonthlyBurnRate, getRunwayMonths, getNetChange, getExpensesByCategory, getCashFlowByMonth src/lib/queries/transactions getTransactions(filters, page, limit) getCategories() direct Drizzle aggregation select · leftJoin · groupBy · orderBy used by get_vendors, get_trends, get_accounts, get_sync_status payroll tables payroll_runs, payroll_line_items, employees safeEvaluate(expression) in-process; no database, no eval, no network UNIT BOUNDARY database and metrics speak cents tool responses speak dollars The conversion happens once, inside the handler, immediately before serialisation. The model never sees a cent value.

← Scroll to see the full diagram →

Twenty tools, one dispatcher. Every definition has a handler and every handler has a definition — there are no orphans in either direction. The eight payroll and compensation handlers are nonetheless untested: no assertion in 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.
2

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.

financialTools — the twelve financial definitions
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_accountsnone
get_sync_statusnone
get_financial_summarynone

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.

The eight payroll and compensation definitions
Tool Parameters Required
get_payroll_summarystartDate, endDatenone
get_employeesactiveOnly (default true), includeLatestPayroll (default false)none
get_payroll_runrunId (uuid), payDatenone
get_employee_payrollemployeeId, employeeName, startDate, endDatenone
get_payroll_trendsmonths (default 12)none
get_payroll_by_departmentstartDate, endDatenone
get_payroll_taxesstartDate, endDatenone
get_team_compensationmonths (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.

3

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.

Error envelope src/lib/chat/tool-handlers.ts
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

Argument handling
InputReaches 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.

Asserted response fields, by tool
ToolReturns
get_metricscash_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 }
4

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”.

The whole cache src/lib/chat/tool-handlers.ts, lines 44–69
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

Cache keys by tool
ToolKey, after the userId: prefix
get_metricsmetrics:${metric}:${days}
get_accountsaccounts
get_vendorsvendors:${limit}:${days}:${direction}
get_trendstrends:${metric}:${comparison}:${customDays}
get_financial_summaryfinancial_summary
get_payroll_summarypayroll_summary:${startDate}:${endDate}
get_employeesemployees:${activeOnly}:${includeLatestPayroll}
get_payroll_trendspayroll_trends:${months}
get_payroll_by_departmentpayroll_dept:${startDate}:${endDate}
get_payroll_taxespayroll_taxes:${startDate}:${endDate}
get_team_compensationteam_compensation:${months}
Not cachedquery_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.

The isolation pattern __tests__/lib/chat/tool-handlers.test.ts
// 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
Tools with asserted cache isolation
ToolWhat is cached
get_accountsThe account list and balances
get_metricsEach metric independently — a repeat call for cash_balance does not re-query
get_vendorsThe aggregated vendor rollup
get_financial_summaryThe 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.

Note — single tenant, multi user

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.

5

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.

safeEvaluate — accepted grammar
FeatureExamples, with asserted results
Four operations1 + 2 → 3 · 5 - 3 → 2 · 3 * 4 → 12 · 10 / 2 → 5
Decimals, negatives1.5 + 2.5 → 4 · -10 * 2 → −20
Precedence2 + 3 * 4 → 14 · 10 - 2 * 3 → 4
Parentheses, nested(2 + 3) * 4 → 20 · ((2 + 3) * 4) + 1 → 21
Unary functionssqrt(16) → 4 · abs(-5) → 5 · floor(1.9) → 1 · ceil(1.1) → 2
Roundinground(1.5) → 2 · round(1.567, 2) → 1.57 — the precision argument is optional
Variadicmin(10, 5, 8) → 5 · max(10, 5, 8) → 10 · pow(2, 3) → 8
CaseSQRT(16) → 4 · Abs(-5) → 5
Percent literal50% → 0.5 · 100 * 20% → 20
Scientific1e3 → 1000 · 1.5e2 → 150
Compositionsqrt(4 + 12) → 4 · sqrt(16) + abs(-5) → 9
Whitespace  1  +  2   → 3 · 1+2*3 → 7 — both extremes tolerated

What it refuses

Thrown messages, verbatim
ExpressionMessage
10 / 0Division by zero
10 + xInvalid characters
(10 + 5Mismatched 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.

6

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.

Model call and route limits — src/app/api/chat/route.ts
SettingValue
Modelclaude-sonnet-4-5-20250929
max_tokens4,096
streamtrue, with the full financialTools array attached
API_TIMEOUT_MS30,000
MAX_TOOL_ITERATIONS10 — the agent loop cannot recurse indefinitely
MAX_REQUEST_SIZE_BYTES1,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.

Exhibit 11
Chat request sequence with tool use and streaming A sequence diagram across five participants: the chat context in the browser, the chat API route, the Claude model, the tool handler layer, and Postgres. The route validates the request, streams text frames, emits tool_start before a tool call and tool_end after it, and terminates with a done frame. Message persistence happens as a background operation. ChatContext browser /api/chat route handler Claude Anthropic SDK tool-handlers handleToolCall Postgres POST { messages: [{ role, content }] } chatRequestSchema.safeParse 1–100 messages, content 1–32,000 chars messages + 20 tool definitions text delta data: {"type":"text","content":"…"} appended to the trailing assistant message tool_use request data: {"type":"tool_start","name":"get_metrics"} handleToolCall(name, input, userId) Drizzle query, or a cache hit JSON string, amounts in dollars data: {"type":"tool_end"} tool result returned to the model; the loop may repeat data: {"type":"done"} on failure instead: {"type":"error","message":"…"} BACKGROUND, NOT AWAITED POST /api/conversations/<id>/messages  ·  PATCH /api/conversations/<id> to set the generated title

← Scroll to see the full diagram →

One turn, with a tool call. The 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: message is skipped silently. The client reads only data:.
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 error frame
Caught by an inner handler and warned, deliberately not promoted to the context's error state — 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.

chatRequestSchema
FieldBoundMessage
messagesmin 1At least one message is required
messagesmax 100Too many messages — 100 passes, 101 fails
roleenumExactly 'user' or 'assistant'. 'system' is rejected, so a client cannot inject a system turn
contentmin 1Message content cannot be empty
contentmax 32,000Message 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.

7

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.

Context value src/contexts/chat-context.tsx
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.

useChat against ChatContext
CapabilityuseChatChatContext
Streams from /api/chatyesyes
Assistant placeholder appended on sendyesyes
Refuses empty input without a requestyesyes
Tool statusno{ isActive, toolName }
Abort in flightnoAbortController in a ref
Persists to the databasenobackground POST and PATCH
On failure the reply readsSorry, 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 name is '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 — isLoading returns to false and toolStatus resets 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.

sanitizeStreamingMarkdown — repairs applied
UnclosedRepair
Code fenceAn odd count of ``` appends \n```
Inline codeAn odd count of backticks outside fenced blocks appends one. Backticks inside a closed fence are excluded from the count
LinkAn open ( after a link text appends ); an open [ with no ( appends ]
BoldAn odd count of ** appends **. Markers inside inline code are excluded
ItalicAn 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.

toolName to rendered text
ToolDisplayed
query_transactionsSearching transactions…
get_metricsCalculating metrics…
calculateRunning calculation…
get_accountsFetching accounts…
get_categoriesLoading categories…
get_vendorsAnalyzing vendors…
get_trendsComparing trends…
get_sync_statusChecking sync status…
anything else, or nullWorking…

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.

SuggestedPrompts, in order
#PromptLikely tool
1What's my current cash balance?get_metrics, cash_balance
2How much did we spend last month?get_monthly_metrics
3What are our biggest expenses?get_metrics, expenses_by_category
4What'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.