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

FileWhat it does
app/page.tsxlanding stub → /login or /chat
app/login/page.tsxGitHub sign-in (+ magic link when AUTH_RESEND_KEY is set)
app/chat/page.tsx + components/chat.tsxminimal streaming chat UI (SSE client)
app/usage/page.tsxusage dashboard: tokens, cost, plan cap, per-model, 30 days
app/pricing/page.tsxpricing cards → Lemon Squeezy checkout links
app/api/agent/route.tsstreaming agent endpoint (SSE) + conversation persistence
app/api/auth/[...nextauth]/route.tsAuth.js handlers
app/api/webhooks/lemonsqueezy/signature-verified webhooks → plan changes / license keys (+ event log)
app/api/download/route.tssigned download link for license buyers
app/admin/licenses/admin: licenses, delivery status, resend / revoke
app/api/admin/licenses/route.tsadmin actions (issue / resend / revoke)
lib/auth/index.tsAuth.js config (Drizzle adapter, session.user.id/plan)
lib/agent/loop.tsagent loop: tools, parallel tool calls, max-turn guard, caching
lib/agent/tools.tstool registry (zod schemas or raw JSON Schema → Claude tool defs)
lib/agent/mcp.tsMCP servers (stdio / HTTP) → tools, configured via MCP_SERVERS
lib/agent/system-prompt.tsstable, cacheable system prompt
lib/billing/pricing.tsversioned price catalog, price selection, cost in micro-USD (pure)
lib/billing/metering.tsper-user metering with a price snapshot (price_id) per row, plan caps
lib/billing/usage.tsusage_events aggregation for /usage
lib/billing/checkout.tsLemon Squeezy checkout URLs with user id as custom data
lib/billing/lemonsqueezy.tswebhook verify + event → plan mapping / license orders
lib/licensing/index.tslicense keys, delivery (GitHub invite + email), signed download tokens
lib/ratelimit/index.tsDB-backed sliding-window rate limit, per-plan requests/minute
lib/email/index.tsResend sender with logging fallback
lib/auth/admin.tsADMIN_EMAILS gate
lib/db/client.tsDrizzle + postgres.js client (pooled, serverless-friendly)
lib/db/schema.tsusers, 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.mdfirst-time setup: Postgres, Anthropic, GitHub OAuth, Lemon Squeezy, MCP, evals
docs/customizing.mdtools, system prompt, MCP servers, eval cases, plans and caps, model
docs/deploy.mdVercel + Neon, other hosts
docs/pricing.mdprice table, price snapshots, changing a price
docs/licensing.mdselling license keys for a one-time product
docs/next16.mdNext.js 16 upgrade evaluation
docs/ROADMAP.mdwhat is planned and what is not in this kit
AGENTS.md, CLAUDE.mdnotes for coding agents: commands and the rules that keep metering correct
All 64 files with line counts
FileLines
.env.example60
.github/workflows/ci.yml37
.gitignore7
AGENTS.md55
CHANGELOG.md95
CLAUDE.md1
LICENSE116
README.md90
app/admin/licenses/actions.tsx107
app/admin/licenses/page.tsx128
app/api/admin/licenses/route.ts51
app/api/agent/route.ts139
app/api/auth/[...nextauth]/route.ts3
app/api/download/route.ts30
app/api/webhooks/lemonsqueezy/route.ts50
app/chat/page.tsx38
app/globals.css12
app/layout.tsx15
app/login/page.tsx68
app/page.tsx23
app/pricing/page.tsx85
app/usage/page.tsx195
components/chat.tsx162
docs/ROADMAP.md25
docs/customizing.md104
docs/deploy.md70
docs/licensing.md51
docs/next16.md42
docs/pricing.md113
docs/setup.md53
drizzle.config.ts9
drizzle/0000_many_sumo.sql84
drizzle/0001_ratelimit_licenses.sql33
drizzle/0002_price_snapshots.sql24
evals/cases.ts45
evals/run.ts137
lib/agent/loop.ts104
lib/agent/mcp.ts143
lib/agent/system-prompt.ts13
lib/agent/tools.ts96
lib/auth/admin.ts23
lib/auth/index.ts78
lib/billing/checkout.ts35
lib/billing/lemonsqueezy.ts160
lib/billing/metering.ts127
lib/billing/pricing.ts154
lib/billing/usage.ts168
lib/db/client.ts33
lib/db/index.ts3
lib/db/schema.ts200
lib/email/index.ts54
lib/licensing/index.ts228
lib/ratelimit/index.ts111
next-env.d.ts6
next.config.ts7
package.json39
postcss.config.mjs5
scripts/load-env.ts8
scripts/seed.ts54
scripts/smoke-agent.ts35
tests/metering.db.test.ts110
tests/pricing.test.ts93
tests/tools.test.ts16
tsconfig.json21

Left out of this list: package-lock.json and the generated migration snapshots in drizzle/meta/.

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.

Agentwright Starter is $99 at the launch price (list $149), paid once, with a 14-day no-questions refund. If it is not what you expected, email us within 14 days and you get your money back.

Buy now — $99 See pricing