Small businesses collect sales, inventory, and customer data but can't afford a data analyst. They need a way to ask business questions in plain language and receive trustworthy, actionable answers without risking runaway cloud costs or data breaches.
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.
You’ll build a natural-language data analysis tool for small businesses. Upload CSVs, ask business questions in plain English, and receive budget-controlled answers generated by Claude inside an E2B sandbox. Along the way you’ll wire up REAA’s budget engine, circuit breakers, evaluation judge, and memory system so every analysis stays affordable and gets better over time. By the end, you’ll have a Next.js 15 app with three API routes, a React dashboard, a history view, and a full test suite.
Prerequisites
Node.js >= 22 (the engines field pins this)
pnpm 10.x (the packageManager field specifies pnpm@10.7.1)
OpenAI API key — for embeddings used by agent memory retrieval
Familiarity with TypeScript, Next.js App Router, and Zod validation
Step 1: Scaffold the Next.js project
You’ll create the project skeleton in an empty directory. Start with package.json, then add TypeScript, Next.js, Vitest, and ESLint configuration plus the base CSS and layout components.
Now create .env.local (this file is git-ignored by Next.js) with your actual keys:
terminal
cp .env.example .env.local
Edit .env.local and replace each placeholder with your real key. The Anthropic key powers Claude code generation, the E2B key creates sandboxed Python environments, and the OpenAI key drives embedding generation for memory retrieval.
Expected output: Your .env.local file has three non-empty values. At runtime, process.env.ANTHROPIC_API_KEY, process.env.E2B_API_KEY, and process.env.OPENAI_API_KEY will be available to server-side code.
Step 3: Create shared types and the Anthropic client
You’ll define the TypeScript interfaces shared across the app, then create a singleton Anthropic SDK client.
import Anthropic from "@anthropic-ai/sdk";export const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY ?? "",});
Expected output: Nothing runs yet, but importing anthropic.messages.create(...) anywhere in the app will use your configured API key.
Step 4: Build the pricing module
The pricing module maps Anthropic model IDs to per-token costs and exposes both exact calculation and estimation functions. The budget controller will call into this to enforce spending limits.
Create src/lib/pricing.ts:
ts
const PRICES: Record<string, { inputPer1M: number; outputPer1M: number }> = { "claude-sonnet-4-6": { inputPer1M: 3, outputPer1M: 15 }, "claude-opus-4-7": { inputPer1M: 15, outputPer1M: 75 }, "claude-haiku-4-5-20251001": { inputPer1M: 0.8, outputPer1M: 4 },};function getRate(model: string): { inputPer1M: number; outputPer1M: number } { const rate = PRICES[model]; if (!rate) { throw new Error(`Unknown model: ${model}`); } return rate;}/** * Full cost calculation using both input and output token counts. */function calculateCost(model: string, inputTokens: number, outputTokens: number): number { const rate = getRate(model); return (inputTokens * rate.inputPer1M) / 1_000_000 + (outputTokens * rate.outputPer1M) / 1_000_000;}/** * Estimate cost for BudgetController's PricingProvider interface: * (modelId, estimatedInputTokens, provider?) => number * Includes a rough output token estimate (30% of input). For exact cost * with known output tokens, use calculateCost instead. */function estimateCost(modelId: string, estimatedInputTokens: number, _provider?: string): number { const rate = getRate(modelId); const estimatedOutputTokens = Math.round(estimatedInputTokens * 0.3); return (estimatedInputTokens * rate.inputPer1M) / 1_000_000 + (estimatedOutputTokens * rate.outputPer1M) / 1_000_000;}export const pricing = { estimateCost, calculateCost };
calculateCost gives you the exact bill once you know the actual token counts. estimateCost guesses the output tokens as 30% of the input and provides a pre-flight budget check.
Step 5: Build the spend store and budget controller
The spend store tracks every transaction in an in-memory store. The budget controller wraps it with the REAA budget engine to enforce per-user spending limits.
Create src/lib/spend-store.ts:
ts
import { SpendStore } from "@reaatech/agent-budget-spend-tracker";export const spendStore = new SpendStore({ maxEntries: 100_000 });
The checkBudget function runs before every analysis. If it returns { allowed: false }, the orchestrator rejects the request immediately. defineUserBudget sets a dollar cap per user that the engine enforces.
Step 6: Set up circuit breakers and the sandbox executor
Circuit breakers prevent cascading failures: if the Anthropic API or E2B sandbox fails repeatedly, subsequent calls are short-circuited for a recovery window. The sandbox executor creates a fresh E2B runtime, runs Python code, and tears it down.
Every call creates a new sandbox, runs the code, and destroys the environment — no state leaks between analyses.
Step 7: Build the judge and prompt builder
The evaluation engine scores Claude’s output using LLM-as-a-judge patterns. If the score falls below 0.7, the orchestrator retries with judge feedback. The prompt builder constructs the system and user prompts sent to Claude.
export function buildAnalysisPrompt( question: string, contextMemories: string, schemaInfo?: string,): { systemPrompt: string; userPrompt: string } { const systemPrompt = "You are a senior data analyst. Generate a single block of Python code inside ```python fences. " + "Use only pandas, matplotlib, and the Python standard library. " + "No network access. No filesystem writes outside the sandbox. " + "Handle edge cases. Return results as print statements."; let userPrompt = `Question: ${question}\n`; if (schemaInfo) { userPrompt += `Schema: ${schemaInfo}\n`; } if (contextMemories) { userPrompt += `Context:\n${contextMemories}\n`; } return { systemPrompt, userPrompt };}
The system prompt constrains Claude to generate safe Python code. The user prompt incorporates the question, optional CSV schema info, and retrieved memories for context.
Step 8: Build the agent memory system
The memory system has three pieces: an AgentMemory instance for extracting and storing facts from question-answer pairs, a MemoryRetriever for semantic search across past analyses, and an analysis store for structured records used by the history endpoint.
Create src/lib/memory/index.ts as the barrel file:
ts
export { memory, storeAnalysisMemory } from "./agent-memory.js";export { retrieveRelevantMemories } from "./retriever.js";export { storeAnalysisRecord, getAnalysisRecords, getStorage } from "./analysis-store.js";export type { AnalysisRecord } from "./analysis-store.js";
Step 9: Write the orchestrator
The orchestrator ties everything together: budget check, memory retrieval, prompt construction, Claude code generation, sandbox execution, evaluation with automatic retry, and spend recording.
Create src/lib/orchestrator.ts:
ts
import { anthropic } from "./anthropic-client.js";import { withLLMCircuitBreaker, withSandboxCircuitBreaker } from "./circuit-breaker.js";import { executePythonCode } from "./sandbox.js";import { evaluateAnalysis } from "./judge.js";import { buildAnalysisPrompt } from "./prompt.js";import { retrieveRelevantMemories, storeAnalysisMemory, storeAnalysisRecord } from "./memory/index.js";import { checkBudget, recordSpend } from "./budget-controller.js";import { pricing } from "./pricing.js";export async function runAnalysis( userId: string, question: string,
The orchestrator first checks the budget — if there’s no money left, it throws a BudgetExceededError. It then retrieves relevant past analyses from memory, builds the prompt, and calls Claude through the circuit breaker. It extracts the Python code block from Claude’s response, runs it in the sandbox, and evaluates the result. If the score is below 0.7, it retries once with the judge’s feedback appended to the system prompt. Finally, it records the spend and stores everything in memory.
Step 10: Add middleware and API routes
The middleware checks every /api/* request for an x-api-key header. The three route handlers expose the orchestrator, history, and CSV upload.
Create middleware.ts at the project root:
ts
import { type NextRequest, NextResponse } from "next/server";export function middleware(req: NextRequest) { const apiKey = req.headers.get("x-api-key"); if (!apiKey) { return new NextResponse(JSON.stringify({ error: "Missing x-api-key header" }), { status: 401, headers: { "Content-Type": "application/json" }, }); } const res = NextResponse.next(); res.headers.set("X-Content-Type-Options", "nosniff"); res.headers.set("X-Frame-Options", "DENY"); return res;}export const config = { matcher: ["/api/:path*"],};
The matcher restricts the middleware to API routes only, so the dashboard and history pages are not affected.
import { type NextRequest, NextResponse } from "next/server";import { spendStore } from "../../../src/lib/spend-store.js";import { BudgetScope } from "@reaatech/agent-budget-types";import { getAnalysisRecords } from "../../../src/lib/memory/index.js";export async function GET(req: NextRequest) { const searchParams = req.nextUrl.searchParams; const userId = searchParams.get("userId"); if (!userId) { return NextResponse.json({ error: "userId required" }, { status: 400 }); }
The history endpoint merges data from two sources — the spend store for accurate cost numbers and the analysis store for question/answer text — then returns paginated results sorted by timestamp.
Create app/api/upload/route.ts:
ts
import { type NextRequest, NextResponse } from "next/server";import { existsSync, mkdirSync, writeFileSync } from "node:fs";import { extname, join } from "node:path";import { randomUUID } from "node:crypto";const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MBconst UPLOAD_DIR = "/tmp/uploads";function parseCsvLine(line: string): string[] { const result: string[] = []; let current = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i] as string; if (char === '"') { if (inQuotes && i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = !inQuotes; } } else if (char === "," && !inQuotes) { result.push(current.trim()); current = ""; } else { current += char; } } result.push(current.trim()); return result;}export async function POST(req: NextRequest) { try { const formData = await req.formData(); const file = formData.get("file"); if (!file || !(file instanceof File)) { return NextResponse.json({ error: "No file provided" }, { status: 400 }); } const fileName = file.name; const ext = fileName.toLowerCase().split(".").pop(); if (ext !== "csv" && ext !== "xlsx") { return NextResponse.json({ error: "Only .csv and .xlsx files are accepted" }, { status: 400 }); } if (file.size > MAX_FILE_SIZE) { return NextResponse.json({ error: "File too large. Maximum size is 10MB" }, { status: 413 }); } const content = await file.text(); const lines = content.split("\n").filter((line) => line.trim().length > 0); if (lines.length === 0) { return NextResponse.json({ error: "Empty file" }, { status: 400 }); } // First line = CSV headers (column names) const headerLine = lines[0]; if (!headerLine) { return NextResponse.json({ error: "Empty file" }, { status: 400 }); } const columns = parseCsvLine(headerLine); // Data rows (skip header) const dataRows = lines.slice(1); const rowCount = dataRows.length; // Ensure upload directory exists if (!existsSync(UPLOAD_DIR)) { mkdirSync(UPLOAD_DIR, { recursive: true }); } const fileId = randomUUID(); const fileExt = extname(fileName); const savedName = fileId + fileExt; const savePath = join(UPLOAD_DIR, savedName); writeFileSync(savePath, content); return NextResponse.json({ fileId, fileName, columns, rowCount, }); } catch { return NextResponse.json({ error: "Upload failed" }, { status: 500 }); }}
The upload handler accepts multipart form data, validates the file type and size, parses the CSV header to extract column names, saves the file under /tmp/uploads/, and returns a fileId you can pass to the analyze endpoint.
Step 11: Build the frontend pages
The dashboard page at / accepts a question and submits it to /api/analyze. The history page at /history fetches past analyses with their costs.
The test suite covers the orchestrator, each API route, the budget controller, circuit breakers, the evaluation judge, and the sandbox integration. Run the full suite with coverage:
terminal
pnpm test
Expected output: All tests pass with at least 90% coverage across lines, branches, functions, and statements. You’ll see a summary table like:
Expected output: The terminal prints ▲ Next.js 15.3.4 followed by - Local: http://localhost:3000.
Open http://localhost:3000 in your browser. Type a question like “Show me a bar chart of monthly revenue” and click Analyze. The page displays the analysis result as JSON, including the generated Python code, the sandbox output, the cost breakdown, and the evaluation score.
You can also test the API directly with curl:
terminal
curl -X POST http://localhost:3000/api/analyze \ -H "Content-Type: application/json" \ -H "x-api-key: demo" \ -d '{"question":"What is 2+2?","userId":"user-1"}'
Expected output: JSON with id, answer, code, output, cost, evalScore, and timestamp fields. The cost field shows the exact USD amount charged for that request.