Tutorial: Build a Token and Context Explorer for Claude
Build a Node.js CLI that counts tokens, reads the context window from the Models API, proves the API is stateless, and measures what effort and thinking cost.
On this page· What you will build
Why this matters
How LLMs work gives you the mental model: tokens, a context window, a stateless API, and reasoning that you pay for as output. This tutorial turns each of those ideas into something you can watch happen on your own terminal. You will build a small CLI that counts tokens, shows how full the context window is, holds a conversation while printing what every turn costs, and compares effort levels on the same question. After this, "the model does not remember" and "thinking costs output tokens" are things you have measured, not things you were told.
What you will build#
Three small scripts that share one client module:
count.ts: count tokens for different kinds of text, and see how much of the context window a file of yours would use.chat.ts: a chat that resends the whole history on every turn and prints input tokens, output tokens, thinking tokens, and cost per turn./forgetempties the history, and the model forgets with it.effort.ts: the same question at low, medium, and high effort, with latency, thinking tokens, and cost side by side.
Hover or tap a component to see what it does.
Show text descriptionHide text description
Memory is a feature of your program, not of the model: the history array is the only reason the conversation continues.
- You
- Chat CLI: Sends each message with the full history, then prints tokens, share of the context window, and cost per turn.
- History: An array of messages in your program. /forget empties it, and the model forgets with it.
- Claude: Sees only what each request contains, so every turn includes everything said so far.
- Models API: models.retrieve returns max_input_tokens, so the window size is never hard-coded.
You need Node.js 22.18 or later and an Anthropic API key in ANTHROPIC_API_KEY (see API keys, billing, and security for how to keep it safe). Token counting is free; running every script in this tutorial a few times costs well under a dollar on claude-opus-5-5.
1. Set up the project#
Create the project
mkdir token-explorer && cd token-explorer npm init -y npm pkg set type=module npm install @anthropic-ai/sdk npm install -D typescript @types/node mkdir srcAdd your API key
.env ANTHROPIC_API_KEY=sk-ant-...Add
.envto.gitignorebefore your first commit. Node.js loads the file with--env-file, so no dotenv package is needed.Add the scripts
npm pkg set scripts.count="node --env-file=.env src/count.ts" scripts.chat="node --env-file=.env src/chat.ts" scripts.effort="node --env-file=.env src/effort.ts" scripts.typecheck="tsc"Copy the
tsconfig.jsonfrom Foundations Tutorial 1 and changeincludeto["src"].
2. One client module#
Everything that knows about the model lives in one file: the client, the model ID, the price, and two helpers.
import Anthropic from "@anthropic-ai/sdk";
// Reads ANTHROPIC_API_KEY from the environment. One client for the whole program.
export const client = new Anthropic();
export const MODEL = "claude-opus-5-5";
// USD per million tokens for claude-opus-5-5 at the time of writing. Check the pricing page.
const PRICE = { input: 4, output: 20 };
export type Effort = "low" | "medium" | "high" | "xhigh" | "max";
/** Cost of one response. Thinking tokens are already included in output_tokens. */
export function costUSD(usage: Anthropic.Usage): number {
return (usage.input_tokens * PRICE.input + usage.output_tokens * PRICE.output) / 1_000_000;
}
/** The text of a response, without thinking blocks. */
export function textOf(message: Anthropic.Message): string {
return message.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("");
}
export const usd = (n: number) => `$${n.toFixed(4)}`;
export const fmt = (n: number) => n.toLocaleString("en-US");textOf filters by block type because a response is a list of content blocks, and with thinking on, the first block is usually a thinking block, not your answer.
3. Count tokens, and see the window#
The token counting endpoint returns exactly how many input tokens a request would use, for the model you name, and it is free. Never estimate with another provider's tokenizer or a characters-per-token rule of thumb.
import { readFile } from "node:fs/promises";
import { client, fmt, MODEL } from "./client.ts";
const SAMPLES: [label: string, text: string][] = [
["English prose", "The quick brown fox jumps over the lazy dog while the farmer watches from the porch."],
["TypeScript", "const total = items.filter((i) => i.active).reduce((sum, i) => sum + i.price * i.qty, 0);"],
["JSON", '{"order_id":"4821","items":[{"sku":"A-17","qty":2},{"sku":"B-03","qty":1}],"status":"shipped"}'],
["French", "Le renard brun rapide saute par-dessus le chien paresseux pendant que le fermier regarde."],
["Numbers", "3.14159 2.71828 1.41421 1.73205 0.57721 1.61803 2.50290 4.66920 0.91596 1.20205"],
];
async function count(text: string): Promise<number> {
const { input_tokens } = await client.messages.countTokens({ model: MODEL, messages: [{ role: "user", content: text }] });
return input_tokens;
}
// The context window comes from the Models API, not from a number copied into the code.
const model = await client.models.retrieve(MODEL);
const contextWindow = model.max_input_tokens ?? 0;
console.log(`${model.display_name}: context window ${fmt(contextWindow)} tokens\n`);
console.log(`${"sample".padEnd(15)}${"chars".padStart(7)}${"tokens".padStart(8)}${"chars/token".padStart(13)}`);
for (const [label, text] of SAMPLES) {
const tokens = await count(text);
console.log(`${label.padEnd(15)}${String(text.length).padStart(7)}${String(tokens).padStart(8)}${(text.length / tokens).toFixed(1).padStart(13)}`);
}
// Optional: how much of the window would a whole file take?
const file = process.argv[2];
if (file) {
const text = await readFile(file, "utf8");
const tokens = await count(text);
const share = tokens / contextWindow;
const bar = "#".repeat(Math.max(1, Math.round(share * 40))).padEnd(40, "-");
console.log(`\n${file}: ${fmt(tokens)} tokens [${bar}] ${(share * 100).toFixed(2)}% of the window`);
}Checkpoint. Run it, passing any file you have nearby:
npm run count -- package-lock.jsonClaude Opus 5.5: context window 1,000,000 tokens
sample chars tokens chars/token
English prose 84 27 3.1
TypeScript 89 33 2.7
JSON 94 45 2.1
French 89 34 2.6
Numbers 79 48 1.6
package-lock.json: 41,210 tokens [#---------------------------------------] 4.12% of the windowThree things to notice in your own output:
- Content type changes the rate. Plain English usually packs the most characters into each token. Code, JSON, digits, and other languages typically need more tokens for the same length, so a JSON-heavy prompt costs more than its character count suggests.
- Each count includes a few tokens of message framing, which is why tiny strings look expensive per character. Measure realistic prompts, not single words.
- The window is large; the bill is not. A file that fills 4% of the window looks harmless, but you pay for those tokens on every request that includes it.
max_input_tokens comes from the Models API at runtime. When a model with a different window ships, the script is still right.
4. A chat that shows its cost#
Now the most important experiment: hold a conversation and watch what each turn sends.
import { createInterface } from "node:readline";
import type Anthropic from "@anthropic-ai/sdk";
import { client, costUSD, type Effort, fmt, MODEL, textOf, usd } from "./client.ts";
const EFFORTS: Effort[] = ["low", "medium", "high", "xhigh", "max"];
const HELP = "commands: /forget /effort <low|medium|high|xhigh|max> /max <tokens> /history /quit";
// The conversation lives here, in your program. The API remembers nothing between requests.
let history: Anthropic.MessageParam[] = [];
let effort: Effort = "low";
let maxTokens = 1024;
let turn = 0;
let totalUSD = 0;
const { max_input_tokens } = await client.models.retrieve(MODEL);
const contextWindow = max_input_tokens ?? 1_000_000;
function command(line: string): boolean {
const [name, arg] = line.split(/\s+/);
if (name === "/forget") {
history = [];
console.log("(history cleared: the next request starts from nothing)");
} else if (name === "/effort" && EFFORTS.includes(arg as Effort)) {
effort = arg as Effort;
console.log(`(effort: ${effort})`);
} else if (name === "/max" && Number(arg) > 0) {
maxTokens = Number(arg);
console.log(`(max_tokens: ${maxTokens})`);
} else if (name === "/history") {
console.log(`(${history.length} messages will be resent with your next message)`);
} else {
console.log(HELP);
}
return true;
}
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
console.log(`Chatting with ${MODEL}. ${HELP}`);
rl.prompt();
for await (const raw of rl) {
const line = raw.trim();
if (line === "/quit") break;
if (line.startsWith("/")) command(line);
else if (line) {
// Every request carries the whole conversation so far.
const messages: Anthropic.MessageParam[] = [...history, { role: "user", content: line }];
const response = await client.messages.create({ model: MODEL, max_tokens: maxTokens, output_config: { effort }, messages });
console.log(`claude> ${textOf(response)}`);
if (response.stop_reason === "max_tokens") console.log(" (cut off: the reply hit max_tokens)");
// Store the assistant turn exactly as received, thinking blocks included.
history = [...messages, { role: "assistant", content: response.content }];
turn++;
const cost = costUSD(response.usage);
totalUSD += cost;
const thinking = response.usage.output_tokens_details?.thinking_tokens ?? 0;
const used = ((response.usage.input_tokens / contextWindow) * 100).toFixed(3);
console.log(
` turn ${turn} · in ${fmt(response.usage.input_tokens)} (${used}% of window) · ` +
`out ${fmt(response.usage.output_tokens)} (thinking ${fmt(thinking)}) · ${usd(cost)} · total ${usd(totalUSD)}`,
);
}
rl.prompt();
}
rl.close();Three lines carry the lessons of the article:
[...history, { role: "user", content: line }]: every request contains the whole conversation. There is no session on the server.{ role: "assistant", content: response.content }: the assistant turn is stored as the content blocks received, not as extracted text, so thinking blocks are replayed unchanged on the next request.output_tokens_details.thinking_tokens: the part ofoutput_tokensthat was reasoning. You pay for it as output, even though the reasoning text is not shown by default.
Checkpoint. Start the chat and try this sequence:
npm run chatyou> Hi, my name is Ada and I am building a support chatbot.
claude> Nice to meet you, Ada! ...
turn 1 · in 21 (0.002% of window) · out 96 (thinking 12) · $0.0020 · total $0.0020
you> What is my name?
claude> Your name is Ada.
turn 2 · in 131 (0.013% of window) · out 15 (thinking 0) · $0.0008 · total $0.0028
you> /forget
(history cleared: the next request starts from nothing)
you> What is my name?
claude> I don't know your name. You haven't told me in this conversation.
turn 3 · in 12 (0.001% of window) · out 22 (thinking 0) · $0.0005 · total $0.0033Look at the in column. Turn 2's input includes turn 1's question and answer. After /forget, input drops back to a dozen tokens, and the model genuinely has no idea who you are: nothing about you was stored anywhere except the array you just emptied. That is statelessness, measured.
Now keep chatting for ten turns and watch the input column climb. Each turn costs more than the last, even when you type short messages, because you pay again for everything said so far. That growth is why long conversations need trimming, summarizing, or prompt caching.
When a reply is cut off#
Type /max 40 and ask for something long, such as a short story. The reply stops early and the CLI prints (cut off: the reply hit max_tokens), because stop_reason is max_tokens instead of end_turn. Code that reads only the text would treat that half answer as complete.
5. What does effort buy?#
Thinking is always on for claude-opus-5-5; effort decides how much of it happens. This script asks one question at three effort levels and prints the trade.
import { client, costUSD, type Effort, fmt, MODEL, textOf, usd } from "./client.ts";
const PROMPT =
"A shop sells pens in packs of 3 for $2 and packs of 5 for $3. " +
"What is the cheapest way to buy exactly 14 pens, and what does it cost? Answer in one sentence.";
const showThinking = process.argv.includes("--show-thinking");
console.log(`${"effort".padEnd(8)}${"seconds".padStart(9)}${"output".padStart(9)}${"thinking".padStart(10)}${"cost".padStart(10)} answer`);
for (const effort of ["low", "medium", "high"] satisfies Effort[]) {
const started = performance.now();
const response = await client.messages.create({
model: MODEL,
max_tokens: 16_000,
// Thinking is always on for this model; effort decides how much of it happens.
thinking: { type: "adaptive", display: showThinking ? "summarized" : "omitted" },
output_config: { effort },
messages: [{ role: "user", content: PROMPT }],
});
const seconds = ((performance.now() - started) / 1000).toFixed(1);
const thinking = response.usage.output_tokens_details?.thinking_tokens ?? 0;
console.log(
`${effort.padEnd(8)}${seconds.padStart(9)}${fmt(response.usage.output_tokens).padStart(9)}` +
`${fmt(thinking).padStart(10)}${usd(costUSD(response.usage)).padStart(10)} ${textOf(response)}`,
);
if (showThinking) {
for (const block of response.content) if (block.type === "thinking" && block.thinking) console.log(` reasoning summary: ${block.thinking}`);
}
}npm run effort
npm run effort -- --show-thinking # also print a readable summary of the reasoningeffort seconds output thinking cost answer
low 2.1 118 64 $0.0026 Buy three 3-packs and one 5-pack, for $9.
medium 3.4 402 338 $0.0083 Buy three packs of 3 and one pack of 5, which costs $9 in total.
high 6.8 1,214 1,140 $0.0245 The cheapest way is three 3-packs and one 5-pack (14 pens) for $9.The answer (three 3-packs and one 5-pack, $9) is the only way to buy exactly 14 pens, so all three levels should agree. What changes is the bill: thinking tokens dominate the output at higher effort, and latency grows with them. For an easy question, higher effort buys nothing. Try it with a genuinely hard problem from your own work and see whether the extra thinking changes the answer. That is the question to ask before raising effort anywhere in production.
display: "summarized" only changes what you see: the model reasons the same amount, and you are billed the same, whether the summary is shown or not.
Common mistakes#
- Estimating tokens from characters. The ratio varies several-fold between prose, code, JSON, and languages. Count with the endpoint for the model you call.
- Storing only the text of replies. The next request needs the content blocks exactly as received. Save
response.content. - Reading the text without checking
stop_reason. A reply cut off atmax_tokenslooks complete to code that never asks. - A tiny
max_tokenswith thinking on. The limit covers reasoning too, so the visible answer can be empty. - Hard-coding the context window. Read
max_input_tokensfrom the Models API, so a model change cannot silently invalidate your limits.
Try it
Extend the explorer
- Add a
/cost100command to the chat that projects the input cost of turn 100 from the growth you have measured so far. When would you start trimming history? - Add a
/trim 6command that keeps only the last six messages, making sure the history still starts with a user message. Ask about something from turn 1 afterwards. What does the model say? - Run
count.tson a real prompt from a project of yours, then compute what it costs per 1,000 requests at $4 per million input tokens. - In
effort.ts, replace the pen puzzle with a hard question from your domain and addxhigh. Does any level change the answer, or only the cost?
Check your understanding
Key takeaways
- Count tokens with the API for the model you call; the characters-per-token ratio depends heavily on the content.
- Read limits such as the context window from the Models API instead of hard-coding them.
- The API is stateless: your program holds the history, resends it on every turn, and pays for it again each time.
- Store and resend
response.contentunchanged, and always checkstop_reason. - Thinking is billed as output; effort controls how much of it happens, so measure whether it changes answers before raising it.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- How LLMs Work: Tokens, Context Windows, Statelessness, and ReasoningA developer's mental model of LLMs: tokens, next-token prediction, the context window, why the API is stateless, and how reasoning works, with runnable code.
- Multi-Turn Chat and Conversation Memory with the Claude APIBuild multi-turn chat in Node.js: keep history, replay content blocks correctly, and control cost with sliding windows, summaries, compaction, and long-term memory.