Agent Cost Dashboard
Build a Claude Agent SDK cost dashboard in Next.js 16. Record per-run estimates, token usage, cache activity, and model mix without confusing estimates with billing.
設定をやめて、構築を始めよう。
AIオーケストレーション付きSaaSビルダーテンプレート。
An Agent SDK cost dashboard records the result of each query() call and turns those estimates into an operational view of run volume, token use, cache activity, and cost trends. The dashboard is useful for engineering optimization, but its dollar totals are estimates and must not be treated as Anthropic billing data.
This guide builds a local version with the Claude Agent SDK and Next.js 16. A runner appends one privacy-conscious record per query to an NDJSON file, and a server-rendered route shows recent usage.
Understand the cost data
The Claude Agent SDK emits a result message when a query finishes. Its total_cost_usd field is the cumulative estimated cost for that one query, including a query that resumes an earlier session. The result can also contain modelUsage, which breaks usage down by model.
Anthropic's official Agent SDK cost tracking guide warns that these values are locally calculated estimates. Pricing changes, unknown models, provider routing, and billing rules can make them differ from an invoice.
| Data source | Best use | Billing authority |
|---|---|---|
Agent SDK total_cost_usd | Compare individual automated runs | No |
Agent SDK modelUsage | Analyze tokens, caching, and model mix | No |
Claude Code /usage | Understand an interactive session or plan allowance | No |
| Anthropic Console or Usage and Cost API | Reconcile organization charges | Yes |
That distinction matters for subscription users too. Anthropic's Claude Code cost guide explains that a locally shown session dollar figure does not represent an extra charge when usage is included in a Pro or Max plan.
For a deeper introduction to the query stream and permission options, start with the Claude Agent SDK tutorial. This article focuses on durable cost telemetry around that stream.
Choose a small storage contract
The dashboard needs enough data to compare runs without retaining prompts, source code, or model output. Store a short human-written label, timestamp, duration, status, estimated dollar value, and aggregate token fields.
The finished example uses this structure:
app/
agent-costs/
page.tsx
lib/
agent-costs.ts
scripts/
run-costed-agent.ts
data/
agent-costs.ndjsonNDJSON places one JSON object on each line and is easy to inspect locally. For concurrent production workloads, replace it with a database and protect the dashboard with authentication.
Install the Agent SDK and tsx, then add commands for the runner and dashboard. These commands assume the project already uses Next.js 16 and TypeScript.
npm install @anthropic-ai/claude-agent-sdk
npm install --save-dev tsx
npm pkg set scripts.agent:costed="tsx scripts/run-costed-agent.ts"
npm pkg set scripts.dev="next dev"For a local script that you control, create an API key in the Anthropic Console and expose it to the process through your shell or secret manager. Anthropic's Agent SDK overview says products built on the SDK should use API-key or supported cloud-provider authentication, not another person's Claude subscription credentials.
export ANTHROPIC_API_KEY="replace-with-a-console-api-key"Do not commit the real value. If you later deploy the runner, store the key in the hosting platform's secret manager and keep it outside the agent's tool-visible filesystem.
Store one record per run
Keep file access in a server-only module. The module below defines the record, appends it atomically enough for a single local process, and tolerates a missing or partially invalid data file when the dashboard starts.
Create lib/agent-costs.ts with the complete implementation. The parser checks the fields used by the page rather than trusting arbitrary JSON, and the module must remain in server-side imports because it reads the local file system.
import { appendFile, mkdir, readFile } from "node:fs/promises";
import path from "node:path";
export type AgentCostRecord = {
id: string;
timestamp: string;
label: string;
status: "success" | "error";
durationMs: number;
estimatedCostUsd: number;
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
models: string[];
};
const dataDirectory = path.join(process.cwd(), "data");
const dataFile = path.join(dataDirectory, "agent-costs.ndjson");
function isAgentCostRecord(value: unknown): value is AgentCostRecord {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Partial<AgentCostRecord>;
return (
typeof record.id === "string" &&
typeof record.timestamp === "string" &&
typeof record.label === "string" &&
(record.status === "success" || record.status === "error") &&
typeof record.durationMs === "number" &&
typeof record.estimatedCostUsd === "number" &&
typeof record.inputTokens === "number" &&
typeof record.outputTokens === "number" &&
typeof record.cacheReadInputTokens === "number" &&
typeof record.cacheCreationInputTokens === "number" &&
Array.isArray(record.models) &&
record.models.every((model) => typeof model === "string")
);
}
export async function appendCostRecord(record: AgentCostRecord) {
await mkdir(dataDirectory, { recursive: true });
await appendFile(dataFile, `${JSON.stringify(record)}\n`, "utf8");
}
export async function readCostRecords(): Promise<AgentCostRecord[]> {
try {
const contents = await readFile(dataFile, "utf8");
return contents
.split("\n")
.filter(Boolean)
.flatMap((line) => {
try {
const parsed: unknown = JSON.parse(line);
return isAgentCostRecord(parsed) ? [parsed] : [];
} catch {
return [];
}
})
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
error.code === "ENOENT"
) {
return [];
}
throw error;
}
}The file contains operational metadata, so add data/agent-costs.ndjson to .gitignore and do not import this module from a Client Component. Keeping run labels generic further reduces the chance of recording a customer name, repository path, or secret.
Capture Agent SDK results
The runner should record exactly one terminal result, even if the query produces many assistant and tool messages. Reading costs from every streamed message and adding them together would overcount a cumulative value.
Create scripts/run-costed-agent.ts with the code below. It totals tokens across the result's model records and uses the result subtype to distinguish successful and failed agent runs.
import { randomUUID } from "node:crypto";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { appendCostRecord } from "../lib/agent-costs";
const prompt = process.argv.slice(2).join(" ").trim();
const label = process.env.AGENT_COST_LABEL ?? "local-project-review";
if (!prompt) {
console.error(
'Usage: npm run agent:costed -- "Summarize the authentication flow"',
);
process.exit(1);
}
let recorded = false;
for await (const message of query({
prompt,
options: {
model: "sonnet",
maxTurns: 6,
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "dontAsk",
},
})) {
if (message.type !== "result") {
continue;
}
const modelEntries = Object.entries(message.modelUsage ?? {});
const totals = modelEntries.reduce(
(sum, [, usage]) => ({
inputTokens: sum.inputTokens + usage.inputTokens,
outputTokens: sum.outputTokens + usage.outputTokens,
cacheReadInputTokens:
sum.cacheReadInputTokens + usage.cacheReadInputTokens,
cacheCreationInputTokens:
sum.cacheCreationInputTokens + usage.cacheCreationInputTokens,
}),
{
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
);
const status = message.subtype === "success" ? "success" : "error";
await appendCostRecord({
id: randomUUID(),
timestamp: new Date().toISOString(),
label,
status,
durationMs: message.duration_ms,
estimatedCostUsd: message.total_cost_usd,
models: modelEntries.map(([model]) => model),
...totals,
});
console.log(
`${label}: $${message.total_cost_usd.toFixed(4)} estimated, ${status}`,
);
process.exitCode = status === "error" ? 1 : 0;
recorded = true;
break;
}
if (!recorded) {
throw new Error("The Agent SDK stream ended without a result message.");
}The official TypeScript reference defines each ModelUsage entry with input tokens, output tokens, cache-read tokens, cache-creation tokens, estimated cost, and model limits. This example stores aggregate token values plus model names; preserve the full per-model object if the product needs model-specific charts.
permissionMode: "dontAsk" makes unattended execution fail instead of waiting for an approval prompt, while the tool allowlist limits the run to repository reading. Adjust those controls to the actual job rather than giving a cost-measurement script broad write or shell access.
Run a few representative queries to create data. Each invocation appends one terminal record to data/agent-costs.ndjson.
npm run agent:costed -- "Summarize the authentication flow"
AGENT_COST_LABEL=test-audit npm run agent:costed -- "Find untested API routes"Collect real development work, then compare similar labels over time. Do not run agents merely to fill a chart.
Render the Next.js dashboard
A Server Component can read the local file without exposing file-system code to the browser. Force dynamic rendering so a page refresh reflects records appended after the Next.js process started.
Create app/agent-costs/page.tsx with this complete page. It calculates a seven-day summary and shows the twenty most recent runs in an accessible table.
import { readCostRecords } from "@/lib/agent-costs";
export const dynamic = "force-dynamic";
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 4,
maximumFractionDigits: 4,
});
const integer = new Intl.NumberFormat("en-US");
function duration(milliseconds: number) {
if (milliseconds < 1_000) {
return `${milliseconds} ms`;
}
return `${(milliseconds / 1_000).toFixed(1)} s`;
}
export default async function AgentCostsPage() {
const records = await readCostRecords();
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1_000;
const recent = records.filter(
(record) => new Date(record.timestamp).getTime() >= sevenDaysAgo,
);
const summary = recent.reduce(
(totals, record) => ({
runs: totals.runs + 1,
cost: totals.cost + record.estimatedCostUsd,
input: totals.input + record.inputTokens,
output: totals.output + record.outputTokens,
cacheRead: totals.cacheRead + record.cacheReadInputTokens,
}),
{ runs: 0, cost: 0, input: 0, output: 0, cacheRead: 0 },
);
const average = summary.runs ? summary.cost / summary.runs : 0;
return (
<main className="mx-auto min-h-screen max-w-7xl px-6 py-12 text-slate-950">
<header className="mb-10 max-w-3xl">
<p className="mb-3 text-sm font-semibold uppercase tracking-widest text-indigo-600">
Last seven days
</p>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
Agent cost dashboard
</h1>
<p className="mt-4 text-lg leading-8 text-slate-600">
Local Agent SDK estimates for engineering analysis. Reconcile charges
in the Anthropic Console.
</p>
</header>
<section
aria-label="Seven day usage summary"
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
{[
["Estimated cost", money.format(summary.cost)],
["Agent runs", integer.format(summary.runs)],
["Average per run", money.format(average)],
["Cache read tokens", integer.format(summary.cacheRead)],
].map(([label, value]) => (
<article
key={label}
className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
>
<p className="text-sm font-medium text-slate-500">{label}</p>
<p className="mt-2 text-2xl font-bold">{value}</p>
</article>
))}
</section>
<section className="mt-10 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div className="border-b border-slate-200 px-6 py-5">
<h2 className="text-xl font-bold">Recent runs</h2>
<p className="mt-1 text-sm text-slate-500">
Showing up to twenty locally recorded Agent SDK queries.
</p>
</div>
{records.length === 0 ? (
<p className="px-6 py-12 text-slate-600">
No runs recorded yet. Use the agent:costed script, then refresh.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
{[
"Time",
"Label",
"Status",
"Models",
"Input",
"Output",
"Duration",
"Estimate",
].map((heading) => (
<th key={heading} scope="col" className="px-5 py-3">
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{records.slice(0, 20).map((record) => (
<tr key={record.id}>
<td className="whitespace-nowrap px-5 py-4 text-slate-600">
{new Date(record.timestamp).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
})}
</td>
<td className="px-5 py-4 font-medium">{record.label}</td>
<td className="px-5 py-4">{record.status}</td>
<td className="px-5 py-4 text-slate-600">
{record.models.join(", ") || "unknown"}
</td>
<td className="px-5 py-4">
{integer.format(record.inputTokens)}
</td>
<td className="px-5 py-4">
{integer.format(record.outputTokens)}
</td>
<td className="px-5 py-4">
{duration(record.durationMs)}
</td>
<td className="px-5 py-4 font-semibold">
{money.format(record.estimatedCostUsd)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="mt-5 text-sm leading-6 text-slate-500">
Estimates can differ from billed usage because pricing and billing
rules may change. Do not use this page for customer billing or
automated spending decisions.
</p>
</main>
);
}The @/lib import assumes the standard Next.js TypeScript alias. The page also assumes Tailwind; use a relative import or CSS Module if the project removed either default.
Start Next.js and open /agent-costs. A refresh will read the latest records because the route is dynamically rendered.
npm run devInterpret the numbers
Compare like-for-like tasks first. A lower average across unrelated labels may simply mean the dashboard recorded more small jobs, while a lower average for the same test-audit job can indicate a genuine improvement.
Input tokens include the conversation and context sent to the model. Output tokens reflect generated content. Cache-read input tokens show reused prompt context, which is generally a better sign than repeatedly creating the same cache material. The prompt caching guide explains how stable context and cache boundaries affect that pattern.
Use three practical views:
- Trend estimated cost and duration by a stable job label.
- Compare success rate before and after changing prompts or tool permissions.
- Watch cache-read volume relative to ordinary input for repeated workflows.
Do not impose a hard stop from local dollar estimates. If an automated workflow needs a safety limit, use deterministic controls such as maxTurns, restricted tools, a job timeout, and workload quotas, then reconcile actual charges through Anthropic's authoritative systems.
The dashboard also should not be used to compare Max plan value directly with API usage. Those products have different payment models; the Max plan versus API guide covers that decision.
Extend it safely
Move records to Postgres or another transactional store before multiple processes can append at once. Add a unique run ID, authenticated access, retention policy, and organization boundary before exposing the page to a team.
Keep prompts and responses out of cost telemetry unless there is a clear, reviewed need to retain them. Labels should be controlled categories, not arbitrary user input. Restrict dashboard access because repository names, job categories, model choices, and timing can still reveal operational information.
For interactive Claude Code rather than the Agent SDK, Anthropic supports OpenTelemetry monitoring for cost, token, session, pull request, commit, and activity metrics. It can segment some metrics by session, model, agent, skill, or plugin. Anthropic notes that cost metrics remain estimates, and enabling detailed prompt or tool telemetry can expose sensitive data.
Once the local version answers a real question, add only the dimensions needed for it.
Frequently asked questions
How do I track Claude Agent SDK cost?
Read the single result message emitted at the end of each query() call and store its total_cost_usd value. Read modelUsage from the same result when you need whole-agent-tree token counts, including work performed by subagents. Do not add total_cost_usd from intermediate stream messages because it is already cumulative for the call.
Is total_cost_usd the same as my Anthropic bill?
No. The Agent SDK calculates total_cost_usd locally from a bundled price table. Anthropic says the estimate can drift when prices change, a model is unknown to the installed SDK, or billing rules differ. Reconcile financial records against the Anthropic Console or Usage and Cost API.
Can a Claude agent cost dashboard show usage by model?
Yes. The result message has a modelUsage map keyed by model name. Each entry contains input, output, cache-read, cache-creation, and estimated cost fields. This tutorial aggregates the token fields and keeps the model names; store each full entry if you need per-model totals or charts.
Code Kit is a $29 one-time harness that runs on top of Claude Code. It helps plan, build, evaluate, and test changes with quality gates for zero type errors, zero lint errors, and a clean production build; Claude Code itself requires a paid Anthropic plan.
Posted by @speedy_devv
設定をやめて、構築を始めよう。
AIオーケストレーション付きSaaSビルダーテンプレート。
Visual Regression Tests
Build visual regression tests with Claude Code, Next.js 16, and Playwright. Create stable screenshot baselines, review diffs, and block accidental UI changes in CI.
Cursor Migration Guide
Migrate a Cursor project to Claude Code without losing rules, commands, MCP servers, or shared agent instructions. Includes a safe conversion script and a verification checklist.