What's inside
Read the code before you buy. This page lists every file in Agentwright Starter 0.4.2 and shows three of them in full, exactly as they ship.
41 TypeScript files, 3,338 lines, 15 tests, 3 database migrations. It is small on purpose: you can read all of it in an afternoon, and after that it is your code. There is no framework of ours to learn and no package of ours to keep installing.
On this page: every file · three files in full · tests and CI · docs · what is not included
Every file
| File | What it does |
|---|---|
app/page.tsx | landing stub → /login or /chat |
app/login/page.tsx | GitHub sign-in (+ magic link when AUTH_RESEND_KEY is set) |
app/chat/page.tsx + components/chat.tsx | minimal streaming chat UI (SSE client) |
app/usage/page.tsx | usage dashboard: tokens, cost, plan cap, per-model, 30 days |
app/pricing/page.tsx | pricing cards → Lemon Squeezy checkout links |
app/api/agent/route.ts | streaming agent endpoint (SSE) + conversation persistence |
app/api/auth/[...nextauth]/route.ts | Auth.js handlers |
app/api/webhooks/lemonsqueezy/ | signature-verified webhooks → plan changes / license keys (+ event log) |
app/api/download/route.ts | signed download link for license buyers |
app/admin/licenses/ | admin: licenses, delivery status, resend / revoke |
app/api/admin/licenses/route.ts | admin actions (issue / resend / revoke) |
lib/auth/index.ts | Auth.js config (Drizzle adapter, session.user.id/plan) |
lib/agent/loop.ts | agent loop: tools, parallel tool calls, max-turn guard, caching |
lib/agent/tools.ts | tool registry (zod schemas or raw JSON Schema → Claude tool defs) |
lib/agent/mcp.ts | MCP servers (stdio / HTTP) → tools, configured via MCP_SERVERS |
lib/agent/system-prompt.ts | stable, cacheable system prompt |
lib/billing/pricing.ts | versioned price catalog, price selection, cost in micro-USD (pure) |
lib/billing/metering.ts | per-user metering with a price snapshot (price_id) per row, plan caps |
lib/billing/usage.ts | usage_events aggregation for /usage |
lib/billing/checkout.ts | Lemon Squeezy checkout URLs with user id as custom data |
lib/billing/lemonsqueezy.ts | webhook verify + event → plan mapping / license orders |
lib/licensing/index.ts | license keys, delivery (GitHub invite + email), signed download tokens |
lib/ratelimit/index.ts | DB-backed sliding-window rate limit, per-plan requests/minute |
lib/email/index.ts | Resend sender with logging fallback |
lib/auth/admin.ts | ADMIN_EMAILS gate |
lib/db/client.ts | Drizzle + postgres.js client (pooled, serverless-friendly) |
lib/db/schema.ts | users, plans, model_prices, usage_events, conversations, webhook_events, rate_limit_hits, licenses, Auth.js tables |
drizzle/ | generated SQL migrations |
evals/ | eval runner + golden cases |
scripts/ | seed (plans + prices), smoke test |
tests/ | unit tests + DB integration test (npm test) |
docs/setup.md | first-time setup: Postgres, Anthropic, GitHub OAuth, Lemon Squeezy, MCP, evals |
docs/customizing.md | tools, system prompt, MCP servers, eval cases, plans and caps, model |
docs/deploy.md | Vercel + Neon, other hosts |
docs/pricing.md | price table, price snapshots, changing a price |
docs/licensing.md | selling license keys for a one-time product |
docs/next16.md | Next.js 16 upgrade evaluation |
docs/ROADMAP.md | what is planned and what is not in this kit |
AGENTS.md, CLAUDE.md | notes for coding agents: commands and the rules that keep metering correct |
All 64 files with line counts
| File | Lines |
|---|---|
.env.example | 60 |
.github/workflows/ci.yml | 37 |
.gitignore | 7 |
AGENTS.md | 55 |
CHANGELOG.md | 95 |
CLAUDE.md | 1 |
LICENSE | 116 |
README.md | 90 |
app/admin/licenses/actions.tsx | 107 |
app/admin/licenses/page.tsx | 128 |
app/api/admin/licenses/route.ts | 51 |
app/api/agent/route.ts | 139 |
app/api/auth/[...nextauth]/route.ts | 3 |
app/api/download/route.ts | 30 |
app/api/webhooks/lemonsqueezy/route.ts | 50 |
app/chat/page.tsx | 38 |
app/globals.css | 12 |
app/layout.tsx | 15 |
app/login/page.tsx | 68 |
app/page.tsx | 23 |
app/pricing/page.tsx | 85 |
app/usage/page.tsx | 195 |
components/chat.tsx | 162 |
docs/ROADMAP.md | 25 |
docs/customizing.md | 104 |
docs/deploy.md | 70 |
docs/licensing.md | 51 |
docs/next16.md | 42 |
docs/pricing.md | 113 |
docs/setup.md | 53 |
drizzle.config.ts | 9 |
drizzle/0000_many_sumo.sql | 84 |
drizzle/0001_ratelimit_licenses.sql | 33 |
drizzle/0002_price_snapshots.sql | 24 |
evals/cases.ts | 45 |
evals/run.ts | 137 |
lib/agent/loop.ts | 104 |
lib/agent/mcp.ts | 143 |
lib/agent/system-prompt.ts | 13 |
lib/agent/tools.ts | 96 |
lib/auth/admin.ts | 23 |
lib/auth/index.ts | 78 |
lib/billing/checkout.ts | 35 |
lib/billing/lemonsqueezy.ts | 160 |
lib/billing/metering.ts | 127 |
lib/billing/pricing.ts | 154 |
lib/billing/usage.ts | 168 |
lib/db/client.ts | 33 |
lib/db/index.ts | 3 |
lib/db/schema.ts | 200 |
lib/email/index.ts | 54 |
lib/licensing/index.ts | 228 |
lib/ratelimit/index.ts | 111 |
next-env.d.ts | 6 |
next.config.ts | 7 |
package.json | 39 |
postcss.config.mjs | 5 |
scripts/load-env.ts | 8 |
scripts/seed.ts | 54 |
scripts/smoke-agent.ts | 35 |
tests/metering.db.test.ts | 110 |
tests/pricing.test.ts | 93 |
tests/tools.test.ts | 16 |
tsconfig.json | 21 |
Dependencies (10), at the versions in the lockfile: @anthropic-ai/sdk 1.3.0 · @auth/drizzle-adapter 1.11.3 · @modelcontextprotocol/sdk 1.32.2 · drizzle-orm 0.45.4 · next 15.5.28 · next-auth 5.0.0-beta.32 · postgres 3.4.9 · react 19.3.1 · react-dom 19.3.1 · zod 4.6.7.
Three files in full
These are the unmodified files from version 0.4.2. They are the parts people most often get wrong when they write this themselves.
The agent loop: lib/agent/loop.ts
The whole loop is about 100 lines. It streams text, runs every tool call of a turn in parallel and returns the results in one message, stops after 12 turns, treats a refusal as its own error, and checks the user's plan cap before each model call. Metering is imported only when a user id is passed, so evals and smoke tests run without a database.
import Anthropic from "@anthropic-ai/sdk";
import { toolDefinitions, runTool } from "./tools";
import { connectMcpServers } from "./mcp";
const client = new Anthropic();
const MAX_TURNS = 12;
/** Default model for the agent. Override per request or via AGENT_MODEL. */
export const DEFAULT_MODEL: Anthropic.Model = (process.env.AGENT_MODEL || "claude-sonnet-5-5") as Anthropic.Model;
export type LoopOptions = {
/**
* User to meter usage against. Omit it (eval runner, smoke tests) to skip the
* database entirely: no plan-cap check and no usage_events row.
*/
userId?: string;
model?: Anthropic.Model;
system: string;
messages: Anthropic.MessageParam[];
maxTokens?: number;
signal?: AbortSignal;
onText?: (delta: string) => void;
onToolCall?: (name: string, input: unknown) => void;
onToolResult?: (name: string, result: unknown, isError: boolean) => void;
onTurn?: (message: Anthropic.Message) => void;
};
export class AgentRefusedError extends Error {
constructor(public readonly details: Anthropic.Message["stop_details"]) {
super("The model declined this request.");
}
}
/**
* Streaming agent loop with tool calls, a max-turn guard and prompt caching on the
* system prompt + tool definitions. Usage is metered per turn against the user's plan.
*
* Returns the full message history (input + everything the agent produced) so the
* caller can persist it and continue the conversation later.
*/
export async function runAgent(opts: LoopOptions): Promise<Anthropic.MessageParam[]> {
const model = opts.model ?? DEFAULT_MODEL;
const messages = [...opts.messages];
await connectMcpServers(); // no-op unless MCP_SERVERS is set
const tools = toolDefinitions();
// Metering is imported lazily so the loop can run without DATABASE_URL when no userId is given.
const metering = opts.userId ? await import("@/lib/billing/metering") : undefined;
// Fail before spending tokens if the model has no price row (it could not be metered).
if (metering) await metering.assertPriced(model);
for (let turn = 0; turn < MAX_TURNS; turn++) {
if (metering && opts.userId) await metering.assertWithinCap(opts.userId);
const stream = client.messages.stream(
{
model,
max_tokens: opts.maxTokens ?? 8192,
system: [{ type: "text", text: opts.system, cache_control: { type: "ephemeral" } }],
tools,
messages,
},
{ signal: opts.signal },
);
stream.on("text", (t) => opts.onText?.(t));
const final = await stream.finalMessage();
if (metering && opts.userId) {
await metering.recordUsage(opts.userId, model, {
input: final.usage.input_tokens,
output: final.usage.output_tokens,
cacheRead: final.usage.cache_read_input_tokens ?? 0,
cacheWrite: final.usage.cache_creation_input_tokens ?? 0,
});
}
opts.onTurn?.(final);
messages.push({ role: "assistant", content: final.content });
if (final.stop_reason === "refusal") throw new AgentRefusedError(final.stop_details);
if (final.stop_reason !== "tool_use") return messages;
// Execute every tool call from this turn in parallel and return all results
// in a single user message (required for parallel tool use to keep working).
const toolUses = final.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
const results = await Promise.all(
toolUses.map(async (block): Promise<Anthropic.ToolResultBlockParam> => {
opts.onToolCall?.(block.name, block.input);
try {
const result = await runTool(block.name, block.input);
opts.onToolResult?.(block.name, result, false);
return { type: "tool_result", tool_use_id: block.id, content: JSON.stringify(result) };
} catch (e) {
const message = (e as Error).message;
opts.onToolResult?.(block.name, message, true);
return { type: "tool_result", tool_use_id: block.id, content: `Tool error: ${message}`, is_error: true };
}
}),
);
messages.push({ role: "user", content: results });
}
throw new Error(`Agent exceeded ${MAX_TURNS} turns`);
}
Per-user metering with price snapshots: lib/billing/metering.ts
Every API response becomes one row: raw token counts, the cost in integer micro-dollars, and the id of the price row that produced it. Monthly totals are sums of stored costs, so they never move when model prices change. A model without a price row fails before the API call, so no request is made that could not be recorded.
import { db } from "@/lib/db";
import { modelPrices, usageEvents, users, plans } from "@/lib/db/schema";
import { and, eq, gte, sql } from "drizzle-orm";
import { costFromPrice, selectPrice, type PriceRow, type Usage } from "./pricing";
export { costMicroUsd, costFromPrice, selectPrice, BUILTIN_PRICES, PRICE_PER_MTOK } from "./pricing";
export type { Usage, PriceRow } from "./pricing";
/**
* The price table changes a few times a year, so it is read once and kept in memory
* for PRICE_CACHE_MS instead of being queried on every request. Rows inserted with a
* future `effective_from` take effect on their date without a restart, because the
* selection happens per request against the cached rows.
*/
const PRICE_CACHE_MS = 60_000;
let priceCache: { rows: PriceRow[]; loadedAt: number } | undefined;
async function loadPrices(): Promise<PriceRow[]> {
if (priceCache && Date.now() - priceCache.loadedAt < PRICE_CACHE_MS) return priceCache.rows;
const rows = await db.select().from(modelPrices);
priceCache = { rows, loadedAt: Date.now() };
return rows;
}
/** Drop the in-memory price table, e.g. after inserting a price row in the same process. */
export function invalidatePriceCache() {
priceCache = undefined;
}
export class MissingPriceError extends Error {
readonly code = "MISSING_PRICE";
constructor(model: string, at: Date) {
super(
`No model_prices row for ${model} at ${at.toISOString()}. Add one (see docs/pricing.md) or run \`npm run db:seed\`.`,
);
}
}
/** The `model_prices` row in effect for this model, request size and time. Throws MissingPriceError. */
export async function currentPrice(model: string, u: Usage = { input: 0, output: 0 }, at = new Date()): Promise<PriceRow> {
const price = selectPrice(await loadPrices(), model, u, at);
if (!price) throw new MissingPriceError(model, at);
return price;
}
/**
* Fails before the API call when the model has no price, so a request is never made
* that could not be recorded. `runAgent` calls this once per run.
*/
export async function assertPriced(model: string): Promise<void> {
await currentPrice(model);
}
/**
* Records one API response: raw token counts, the cost computed now, and the id of
* the price row used. Totals are sums of `cost_micro_usd`, so later price changes
* never rewrite history; `price_id` explains every individual charge.
*/
export async function recordUsage(userId: string, model: string, u: Usage, at = new Date()) {
const price = await currentPrice(model, u, at);
const costMicroUsd = costFromPrice(price, u);
await db.insert(usageEvents).values({
userId,
model,
inputTokens: u.input,
outputTokens: u.output,
cacheReadTokens: u.cacheRead ?? 0,
cacheWriteTokens: u.cacheWrite ?? 0,
costMicroUsd,
priceId: price.id,
createdAt: at,
});
return { priceId: price.id, costMicroUsd };
}
/** Thrown by `assertWithinCap` when the user or their plan row is not in the database. */
export class MeteringSetupError extends Error {
constructor(
readonly code: "NO_USER" | "NO_PLAN",
message: string,
) {
super(message);
}
}
/**
* Throws `CAP_REACHED` when the user's plan has no overage and the user has already
* used `plans.monthly_token_cap` tokens this month. What it counts:
*
* - input + output tokens from `usage_events`, since 00:00 UTC on the 1st of the
* current UTC month. Cache read and cache write tokens are not counted (they are
* still charged in `cost_micro_usd`).
* - `runAgent` calls it before each model turn, with the usage recorded so far. A
* turn that starts under the cap is not stopped, so a single turn (and the tokens
* of turns already running in parallel requests) can take the total past the cap.
* The next turn is then refused.
*
* Throws `NO_USER` when `userId` is not in `users`, and `NO_PLAN` when the user's
* `plan` has no row in `plans` (usually because `npm run db:seed` has not been run).
*/
export async function assertWithinCap(userId: string): Promise<void> {
const monthStart = new Date();
monthStart.setUTCDate(1);
monthStart.setUTCHours(0, 0, 0, 0);
const [user] = await db.select({ plan: users.plan }).from(users).where(eq(users.id, userId));
if (!user) {
throw new MeteringSetupError("NO_USER", `User ${userId} not found in the users table, so the plan cap cannot be checked.`);
}
const [plan] = await db.select().from(plans).where(eq(plans.id, user.plan));
if (!plan) {
throw new MeteringSetupError(
"NO_PLAN",
`Plan "${user.plan}" (user ${userId}) has no row in the plans table. Run \`npm run db:seed\` to create the default plans, or insert a row for "${user.plan}".`,
);
}
const [{ used }] = await db
.select({ used: sql<number>`coalesce(sum(${usageEvents.inputTokens} + ${usageEvents.outputTokens}), 0)` })
.from(usageEvents)
.where(and(eq(usageEvents.userId, userId), gte(usageEvents.createdAt, monthStart)));
if (Number(used) >= plan.monthlyTokenCap && !plan.overageAllowed) {
const err = new Error("Monthly token cap reached. Upgrade your plan to continue.");
(err as any).code = "CAP_REACHED";
throw err;
}
}
Eval cases: evals/cases.ts
Golden cases run through the real agent loop with the same system prompt and tools. Checks are deliberately simple: which tools must be called, which must not, what the answer has to contain, and an upper bound on turns. The runner prints cost per case and exits non-zero on failure.
/**
* Golden eval cases. Each case sends one user message through the real agent loop
* (same system prompt, same tools) and checks the final answer.
*
* Checks are deliberately simple so they are cheap to maintain:
* - `expectTools`: tools the agent must call at least once
* - `expectNoTools`: the agent must not call any tool
* - `includes`: case-insensitive substrings that must appear in the final text
* - `matches`: regular expressions the final text must match
* - `maxTurns`: upper bound on model turns (catches runaway loops)
*/
export type EvalCase = {
id: string;
input: string;
expectTools?: string[];
expectNoTools?: boolean;
includes?: string[];
matches?: RegExp[];
maxTurns?: number;
};
export const cases: EvalCase[] = [
{
id: "tool-use-time",
input: "What is the current UTC time? Use your tool and answer with the ISO timestamp.",
expectTools: ["get_time"],
matches: [/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/],
maxTurns: 2,
},
{
id: "no-tool-arithmetic",
input: "What is 17 * 23? Reply with just the number.",
expectNoTools: true,
includes: ["391"],
maxTurns: 1,
},
{
id: "honest-limits",
input: "Please send an email to my boss saying I'll be late.",
expectNoTools: true,
// The system prompt asks the agent to say plainly when it cannot do something.
matches: [/can(?:'|’)?t|cannot|unable|don(?:'|’)?t have|no (?:email|tool)/i],
maxTurns: 1,
},
];
Tests and CI
npm test runs 15 tests in 3 files: price selection and cost math, the tool registry (including the regression test for the 4-cache-breakpoint limit), and a metering test against a real Postgres database. The GitHub Actions workflow that ships with the kit runs the type check, the tests with a Postgres service, and next build on every pull request:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
env:
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
- name: next build
run: npm run build
env:
# The db client connects lazily per query, but reads DATABASE_URL at import.
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
AUTH_SECRET: ci-placeholder-secret
npm run eval is separate, because it calls the Claude API and costs money: three golden cases on Claude Haiku 5.5 cost well under one cent per run.
Both commands, run on version 0.4.2 on November 11, 2026. This is the terminal output:
$ npm test
> agentwright-starter@0.4.2 test
> tsx --test tests/*.test.ts
✔ migration seeds the price catalog (191.231499ms)
✔ recordUsage stores cost and price_id; a later price row does not change history (179.649277ms)
✔ unknown model fails before anything is recorded (0.634417ms)
✔ assertWithinCap: a missing plan row or user is a clear error, not a TypeError (7.206581ms)
✔ checkRateLimit: parallel requests for one subject never exceed the limit (70.605894ms)
✔ built-in ids are unique and rates are integers (1.042023ms)
✔ current list prices (official pricing page, 2026-11-04) (1.274969ms)
✔ Claude Fable 5.2 row (official pricing page, 2026-11-11) (0.210474ms)
✔ worked example: 2,000 Sonnet requests = $40 (0.098611ms)
✔ cost includes cache read and write (0.074623ms)
✔ Haiku 5.5 switches to the long-context row above 100,000 prompt tokens (0.122804ms)
✔ a new row takes effect on its date and old dates keep the old row (0.145353ms)
✔ unknown model or a date before any row has no price (0.286775ms)
✔ formatRate (0.335253ms)
✔ tool definitions are sorted and carry no cache_control (API allows max 4 breakpoints) (7.278277ms)
ℹ tests 15
ℹ suites 0
ℹ pass 15
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 730.873396
$ npm run eval -- --model claude-haiku-5-5
> agentwright-starter@0.4.2 eval
> tsx evals/run.ts --model claude-haiku-5-5
Running 3 case(s) on claude-haiku-5-5
PASS tool-use-time turns=2 tools=[get_time] $0.0001 1370ms
PASS no-tool-arithmetic turns=1 tools=[] $0.0001 667ms
PASS honest-limits turns=1 tools=[] $0.0001 1166ms
3/3 passed · 2057 tokens · $0.0003 total
Docs that ship with it
- Setup: Postgres, the Anthropic key, GitHub sign-in, Lemon Squeezy, MCP servers and evals: the steps from zip to a running app.
- Deploy on Vercel and Neon: The zero-ops path: Neon for Postgres, Vercel for the app, the webhook, a post-deploy checklist and notes for other hosts.
- Model prices and price snapshots: How every usage row stores its cost and the price version used, and how to add a price change without rewriting history.
- License key delivery: If you sell a digital product yourself: issuing license keys on purchase, signed download links, and the admin page.
- Changelog: every release since 0.1.0, with what changed and why.
What is not included
So you can decide quickly whether it fits:
- Organizations, teams and seats. Accounts are single users. If you sell to companies that need invites and roles, you build that part.
- Other payment providers. Lemon Squeezy is wired in. Stripe, Polar or Paddle means rewriting the webhook route and checkout helpers; metering and plan caps do not depend on the provider.
- A marketing site or component library. The UI is deliberately plain: sign-in, chat, usage dashboard, pricing and an admin page, styled with Tailwind. You bring your own design.
- Other model providers. It is built on the Anthropic SDK and Claude's features (prompt caching, tool use, stop reasons). It is not a multi-provider abstraction.
- A hosted service. You get source code. You run it on your own Vercel, Neon and Anthropic accounts.