Small business owners using QuickBooks can't easily perform ad-hoc financial analysis—like cash flow forecasting or expense categorization—without hiring a data analyst or learning complex spreadsheet formulas. Off-the-shelf chat tools either hallucinate numbers or lack safe access to live financial data.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial walks you through building a conversational financial analysis chat for QuickBooks. You’ll wire up Vertex AI to generate Python code on the fly, run that code in a secure e2b sandbox, enforce spend controls and human-in-the-loop approvals, and surface natural-language answers to questions like “What was our net income last month?” — all behind a Next.js chat interface. Along the way you’ll work with five @reaatech/* packages for session continuity, tool-use firewalling, approval workflows, cost telemetry, and structured output repair.
Prerequisites
Node.js >= 22 and pnpm >= 10 installed
A Google Cloud project with the Vertex AI API enabled and a service account key
An e2b account with an API key (free tier works)
A QuickBooks Online account with OAuth 2.0 credentials (consumer key, secret, access token, refresh token, and realm ID)
A Slack workspace with a bot token and a channel ID for approval notifications
Familiarity with TypeScript, Next.js App Router routes, and basic async/await patterns
Step 1: Scaffold the Next.js project and install dependencies
Start from an empty directory and create the Next.js project with the App Router. This tutorial pins every dependency to exact versions so you can follow along without surprises.
Now copy the environment template and fill in your credentials:
terminal
cp .env.example .env
Expected output: Your .env file should contain placeholders for all the services. Here is the complete list of variables the recipe reads:
env
# Env vars used by vertex-ai-code-sandbox-for-quickbooks-financial-modeling.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# Vertex / GCP authGOOGLE_CLOUD_PROJECT=<your-gcp-project-id>GOOGLE_CLOUD_LOCATION=us-central1GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json# e2b sandboxE2B_API_KEY=e2b_<your-e2b-key># QuickBooks OAuthQUICKBOOKS_CONSUMER_KEY=<your-key>QUICKBOOKS_CONSUMER_SECRET=<your-secret>QUICKBOOKS_OAUTH_TOKEN=<your-token>QUICKBOOKS_REALM_ID=<your-realm>QUICKBOOKS_REFRESH_TOKEN=<your-refresh-token># Slack notificationSLACK_TOKEN=<your-slack-bot-token>SLACK_APPROVAL_CHANNEL=<channel-id># Budget & sessionMONTHLY_LLM_BUDGET_USD=100.0SESSION_MAX_TOKENS=4096APPROVAL_API_KEY=<your-approval-api-key>
Step 2: Define the shared types
Create src/types/index.ts with the types that every service and route handler imports. These define the contract between the chat endpoint, the Vertex AI client, the code sandbox, and the approval system.
The VertexAIClient wraps @google-cloud/vertexai and provides two key methods: generate for simple text generation and generateWithCodeExecution which passes an execute_code function declaration so the model can request code execution as a tool call.
The SessionManagerService wraps @reaatech/session-continuity to manage conversation history with token budgeting and automatic compression. It includes an in-memory storage adapter and a simple character-count token counter so you can get started without a database.
Create src/services/session-manager.ts:
ts
import { SessionManager, SessionNotFoundError, TokenBudgetExceededError, ConcurrencyError,} from '@reaatech/session-continuity'import type { IStorageAdapter, TokenCounter, Session, Message, HealthStatus, ConversationContextResult,} from '@reaatech/session-continuity'export class InMemoryStorageAdapter implements IStorageAdapter { private sessions = new Map<string, Session>() private messages = new Map<string, Message[]>() createSession(
Step 5: Set up the QuickBooks client
The QuickBooksService wraps node-quickbooks — a callback-based SDK — with a promisify wrapper so you can use async/await. It exposes one method per report type (balance sheet, profit & loss, cash flow, etc.) plus a queryByIntent dispatcher.
The CodeExecutorService wraps the e2b sandbox SDK. It spins up a sandbox, runs the generated Python or JavaScript code inside it, and returns stdout, stderr, the exit code, and execution time. Empty code is short-circuited without creating a sandbox. A 30-second timeout with AbortController prevents runaway scripts.
The CodeExecutionFirewall uses @reaatech/tool-use-firewall-core to check code length, tool allowlists, and high-risk patterns before execution proceeds. It throws PolicyViolationError for blocked operations and ApprovalRequiredError when human review is needed.
Create src/services/tool-firewall.ts:
ts
import { PolicyViolationError, ApprovalRequiredError, Logger } from "@reaatech/tool-use-firewall-core";export class CodeExecutionFirewall { private policy: { maxCodeLength: number; allowedTools: string[]; requireApprovalFor: string[] }; private log: Logger; constructor(policy: { maxCodeLength: number; allowedTools: string[]; requireApprovalFor: string[] }) { this.policy = policy; this.log = new Logger("CodeExecutionFirewall"); } evaluate(args: { toolName: string; code: string; language: string; sessionId: string }): { allowed: true; action: "CONTINUE" } { const safeArgs = { code: args.code, toolName: args.toolName }; this.log.info(`Evaluating tool: ${args.toolName}`, safeArgs); if (args.code.length > this.policy.maxCodeLength) { throw new PolicyViolationError({ message: `Code exceeds maximum allowed length of ${String(this.policy.maxCodeLength)} characters`, }); } for (const pattern of this.policy.requireApprovalFor) { if (args.toolName.includes(pattern)) { throw new ApprovalRequiredError({ message: `High-risk operation requires approval: ${args.toolName} matches pattern "${pattern}"`, approvalId: crypto.randomUUID(), }); } } if (!this.policy.allowedTools.includes(args.toolName)) { throw new PolicyViolationError({ message: `Tool "${args.toolName}" is not in the allowed tools list`, }); } return { allowed: true, action: "CONTINUE" as const }; }}
Step 8: Add the Slack approval workflow
The ApprovalService uses @reaatech/tool-use-firewall-approvals to manage the approval lifecycle and posts a Slack notification when human review is needed. It exposes requestCodeExecutionApproval, approveExecution, denyExecution, and getApprovalStatus.
The CostTelemetryService wraps @reaatech/llm-cost-telemetry to record token usage per LLM call and enforce a monthly budget. The wrapVertexCall method intercepts calls to Vertex AI, reads usage metadata from the response, and records the spend automatically.
Step 10: Build the structured output repair service
The OutputRepairService uses @reaatech/structured-repair-core along with a Zod schema to validate and repair the JSON that the sandbox produces. This catches common LLM output problems like markdown fences around JSON, trailing commas, and type coercion issues.
The POST /api/chat route is the heart of the application. It accepts a user message, resolves or creates a session, sends the conversation context to Vertex AI with the execute_code function declaration, runs approved code through the firewall and sandbox, repairs the output, synthesizes a plain-English answer, and returns it with cost metadata.
Create app/api/chat/route.ts:
ts
import { type NextRequest, NextResponse } from "next/server";import { VertexAIClient } from "../../../src/services/vertex-client.js";import { SessionManagerService } from "../../../src/services/session-manager.js";import { CodeExecutorService } from "../../../src/services/code-executor.js";import { CodeExecutionFirewall } from "../../../src/services/tool-firewall.js";import { ApprovalService } from "../../../src/services/approval-workflow.js";import { CostTelemetryService } from "../../../src/services/cost-telemetry.js";import { OutputRepairService } from "../../../src/services/output-repair.js";import type { ChatRequest, ChatResponse } from "../../../src/types/index.js";const vertexClient =
Step 12: Create the approval callback route
The approvals/[approvalId] route handles the human-in-the-loop workflow. An admin calls POST with x-api-key authentication to approve or deny a pending execution. Approving it triggers the sandbox execution and output repair inline. GET returns the current status.
Create app/api/approvals/[approvalId]/route.ts:
ts
import { type NextRequest, NextResponse } from "next/server";import { ApprovalService } from "../../../../src/services/approval-workflow.js";import { CodeExecutorService } from "../../../../src/services/code-executor.js";import { OutputRepairService } from "../../../../src/services/output-repair.js";const approvalService = new ApprovalService();const codeExecutor = new CodeExecutorService();const outputRepair = new OutputRepairService();export const pendingExecutions = new Map<string, { code: string; language: string; sessionId: string }>();export async function POST( req: NextRequest, { params }: { params: Promise<{ approvalId: string }> }) { try { const apiKey = req.headers.get("x-api-key"); if (apiKey !== process.env.APPROVAL_API_KEY) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { approvalId } = await params; const body = (await req.json()) as { action: string; reason?: string }; const { action, reason } = body; if (!action || (action !== "approve" && action !== "deny")) { return NextResponse.json( { error: 'action must be "approve" or "deny"' }, { status: 400 } ); } if (action === "deny") { const result = await approvalService.denyExecution(approvalId, reason ?? "Denied by admin"); pendingExecutions.delete(approvalId); return NextResponse.json({ result }); } const pending = pendingExecutions.get(approvalId); if (!pending) { return NextResponse.json( { error: "No pending execution found for this approval" }, { status: 404 } ); } await approvalService.approveExecution(approvalId); const execResult = await codeExecutor.executeCode( pending.code, pending.language as "python" | "javascript" ); const repaired = await outputRepair.repairCodeExecutionOutput(execResult.stdout); pendingExecutions.delete(approvalId); return NextResponse.json({ approved: true, execution: { stdout: execResult.stdout, stderr: execResult.stderr, exitCode: execResult.exitCode }, repaired, }); } catch (error) { console.error("Approval error:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); }}export async function GET( req: NextRequest, { params }: { params: Promise<{ approvalId: string }> }) { try { const apiKey = req.headers.get("x-api-key"); if (apiKey !== process.env.APPROVAL_API_KEY) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { approvalId } = await params; const status = approvalService.getApprovalStatus(approvalId); return NextResponse.json({ status }); } catch (error: unknown) { console.error("Approval status error:", error instanceof Error ? error.message : String(error)); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); }}
Step 13: Build the chat UI
The page at app/page.tsx provides a functional, inline-styled chat interface. It posts messages to /api/chat, displays the conversation history, shows approval status when code execution needs review, and persists the session ID across turns.
Create src/index.ts to export every public service so consumers can import them from a single entry point:
ts
export { VertexAIClient } from "./services/vertex-client.js";export { SessionManagerService } from "./services/session-manager.js";export { QuickBooksService } from "./services/quickbooks-client.js";export { CodeExecutorService } from "./services/code-executor.js";export { CodeExecutionFirewall } from "./services/tool-firewall.js";export { ApprovalService } from "./services/approval-workflow.js";export { CostTelemetryService } from "./services/cost-telemetry.js";export { OutputRepairService } from "./services/output-repair.js";export type * from "./types/index.js";
Step 15: Run the tests
The recipe ships with a full test suite covering every service and route handler. All external services — Vertex AI, e2b, Slack, QuickBooks — are mocked with vi.mock so no API keys are needed to run them.
Expected output: TypeScript compiles with zero errors (pnpm typecheck passes cleanly). ESLint reports no issues (pnpm lint exits with code 0). Vitest runs 23 test suites covering all services (tests/services/) and API routes (tests/api/) — all 159 tests pass with coverage exceeding 90% across lines, branches, functions, and statements.
Next steps
Switch to a persistent database — Replace InMemoryStorageAdapter with PostgreSQL or Redis so sessions survive server restarts. Use @reaatech/session-continuity’s storage adapter interface.
Add real Slack approval buttons — Instead of a raw text message, post interactive Slack message buttons (Approve / Deny) and wire up the Slack Events API to your approval callback route.
Expose a dashboard for cost tracking — The CostTelemetryService records every span in memory. Persist spans to a database and build a dashboard showing daily/weekly/monthly LLM spend trends.
Expand the code generation scope — Add more function declarations to Vertex AI (e.g. chart_data, forecast_cash_flow) and extend the sandbox with common Python libraries like pandas and matplotlib.
interface ChatSession {
sendMessage(message: string): Promise<{
response: {
candidates?: Array<{
content: {
parts: Array<{ text?: string }>;
};
}>;
};
}>;
}
export class VertexAIClient {
private model: GenerativeModel;
constructor(config: VertexConfig) {
const vertexAI = new VertexAI({ project: config.project, location: config.location });