Chris Tam · Last updated 19 October 2026
Claude’s tool loop makes several model calls for one task. I let AmplitudeToolLoop record those calls and tool results, then give it a plain Anthropic client so agent analytics does not count each response twice.
I’ll build a TypeScript script with two sample support tools, cache-aware token counts, and an upload wait before exit. The Claude client adapter supplies the max_tokens value required by the Messages API.
A runSupportAgent() function that sends a support question to Claude with two tools, get_order_status and search_help_docs, and records [Agent] User Message, one [Agent] AI Response per model call, and one [Agent] Tool Call per tool run, all under one [Agent] Session ID per task and one [Agent] Agent ID (support-agent). Each AI Response carries [Agent] Model Name, [Agent] Input Tokens, [Agent] Output Tokens, [Agent] Cost USD, and [Agent] Latency Ms. By the end you'll see those events in your test user's event stream and the task in agent analytics.
Prerequisites
- A Node.js 20.6 or later TypeScript project that runs as ES modules (
"type": "module"inpackage.json). - An Anthropic API key in
ANTHROPIC_API_KEY. - An Amplitude project with agent analytics enabled, and its project API key. In Amplitude, go to Settings > Projects > your project > General, then Manage next to API Key. Agent data can live in the same project as your product analytics, which is the simplest setup.
- The View Agent Analytics Objects permission on your role, which you need to open agent analytics at all.
No Amplitude account yet? A free account is enough for this tutorial; every plan includes a free allotment of agent sessions. Sign up for free to get your project API key.
These examples use a US-region Amplitude project. For an EU project, supply an EU-configured analytics client instead of initializing with the API key alone.
1. Install the SDKs
The Node path needs @amplitude/ai and the Node analytics client it sends through. Provider packages are optional peer dependencies, so add @anthropic-ai/sdk yourself. tsx runs the TypeScript files directly. For every option, see the Amplitude AI SDK reference.
pnpm add @amplitude/ai @amplitude/analytics-node @anthropic-ai/sdk
pnpm add -D tsx typescript @types/node# .env
AMPLITUDE_AI_API_KEY=your-amplitude-project-api-key
ANTHROPIC_API_KEY=your-anthropic-api-keyPrefer to let a coding agent do the instrumentation? Tell it to fetch and follow https://raw.githubusercontent.com/amplitude/Amplitude-AI-Node/main/amplitude-ai.md, the instructions file Amplitude publishes for coding agents. The steps below are what it should produce for a Claude tool loop.
2. Initialize Amplitude and the Claude client once
Create one module that constructs the Amplitude client, the agent, and the Claude client at module scope. Everything else imports from it.
// src/lib/amplitude.ts
import Anthropic from '@anthropic-ai/sdk';
import { AIConfig, AmplitudeAI } from '@amplitude/ai';
const apiKey = process.env.AMPLITUDE_AI_API_KEY;
if (!apiKey) {
throw new Error('AMPLITUDE_AI_API_KEY is not set');
}
export const ai = new AmplitudeAI({
apiKey,
config: new AIConfig({
contentMode: 'full', // prompt and response text, with PII redaction
redactPii: true,
debug: process.env.NODE_ENV !== 'production',
}),
});
export const supportAgent = ai.agent('support-agent', {
description: 'Answers order and help-center questions with tools',
});
// Wait for a promise-returning client or the analytics return object's promise.
export async function flushAgentEvents(): Promise<void> {
const pending = ai.flush();
if (pending && typeof pending === 'object' && 'promise' in pending) {
await pending.promise;
} else {
await pending;
}
}
export const MODEL = 'claude-sonnet-5-5';
// The plain Anthropic client, not the wrapper from @amplitude/ai.
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
type LoopRequest = Omit<Anthropic.MessageCreateParamsNonStreaming, 'max_tokens'> & {
max_tokens?: number;
};
// AmplitudeToolLoop calls client.messages.create for you. This client adds
// max_tokens, which the Messages API requires on every request.
export const claude = {
messages: {
create: (params: LoopRequest) =>
anthropic.messages.create({ ...params, max_tokens: params.max_tokens ?? 4096 }),
},
};Why. The loop records every response and tool call itself, so give it the plain Anthropic client. A client wrapped with new Anthropic({ apiKey, amplitude: ai }) would record each response a second time. The loop builds each request from the model, messages, tools, and system prompt you pass it, and the Messages API rejects a request without max_tokens, so the small claude adapter sets it. Thinking tokens count toward max_tokens, so leave room above the length of the answer you expect.
Keep the named agent at module scope so tasks reuse its configuration. contentMode: 'full' is the default and sends prompt and response text after scrubbing emails, phone numbers, SSNs, card numbers, and IP addresses. If tool inputs or outputs must stay in your infrastructure, use 'metadata_only': you still get tokens, latency, model, cost, and session grouping, with no text. debug prints each tracked event to stderr outside production.
Use the canonical model ID, claude-sonnet-5-5, not a gateway alias. Cost comes from token counts multiplied against a public model-price catalog, and a name the catalog can't price gets no cost.
Redaction matches patterns; it does not remove every kind of personal data. Review representative payloads before production capture. In metadata_only, built-in quality signals that need message text are unavailable.
3. Define the tools and the executor
Tool definitions follow Anthropic's schema: a name, a description that says what the tool does and when to use it, and an input_schema in JSON Schema. The executor is one function that receives the name and input of each tool_use block Claude returns and runs the matching code.
// src/tools.ts
import type Anthropic from '@anthropic-ai/sdk';
export const tools = [
{
name: 'get_order_status',
description:
'Looks up the shipping status of one order by its order number. Use it when the user asks where an order is, when it ships, or whether it arrived. Returns the status, carrier, and estimated delivery date. It does not return payment or refund details.',
input_schema: {
type: 'object',
properties: {
order_id: { type: 'string', description: 'The order number, for example 1042.' },
},
required: ['order_id'],
},
},
{
name: 'search_help_docs',
description:
'Searches the help center for articles about returns, refunds, shipping, and account settings. Use it for policy questions the order record cannot answer. Returns up to three matching articles with a title and body.',
input_schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'A short search phrase, for example "return window".' },
},
required: ['query'],
},
},
] satisfies Anthropic.Tool[];
async function getOrderStatus(orderId: string) {
// Replace with a call to your order service.
return { order_id: orderId, status: 'shipped', carrier: 'UPS', estimated_delivery: '2026-10-18' };
}
async function searchHelpDocs(query: string) {
// Replace with your search: a vector store, a database, or an internal API.
return [{ title: 'Return an item', body: 'Start a return from Orders within 30 days of delivery.' }];
}
// AmplitudeToolLoop calls this once for each tool_use block in Claude's response.
export async function executeTool(name: string, input: Record<string, unknown>): Promise<string> {
switch (name) {
case 'get_order_status':
return JSON.stringify(await getOrderStatus(String(input.order_id)));
case 'search_help_docs':
return JSON.stringify(await searchHelpDocs(String(input.query)));
default:
throw new Error(`Unknown tool: ${name}`);
}
}Why. Return a string. What the executor returns becomes the tool_result content Claude reads next and the [Agent] Tool Output you see in Amplitude, so stringify objects yourself. When the executor throws, the loop sends Claude a tool_result with is_error: true and records the Tool Call with [Agent] Tool Success false, so let real failures throw instead of returning an error-shaped success.
satisfies checks the definitions against Anthropic's Tool type while keeping them plain objects, which is the shape the loop accepts.
4. Run the tool loop inside an agent session
AmplitudeToolLoop runs Anthropic's multi-turn tool_use loop: it calls Claude, runs every tool Claude asks for through your executor, sends the results back, and repeats until Claude answers. Open an agent session around it, keyed on an ID your app already has for the task, such as a ticket, thread, or run ID.
// src/agent.ts
import type Anthropic from '@anthropic-ai/sdk';
import { AmplitudeToolLoop } from '@amplitude/ai';
import { ai, claude, MODEL, supportAgent } from './lib/amplitude.js';
import { executeTool, tools } from './tools.js';
const SYSTEM_PROMPT =
'You are the support agent for an online store. Use the tools to look up orders and help articles. Answer in two or three sentences.';
export interface SupportTask {
taskId: string; // a ticket, thread, or run ID your app already has
prompt: string;
userId?: string; // the same user ID your product events carry; omit when signed out
deviceId?: string;
}
export async function runSupportAgent({ taskId, prompt, userId, deviceId }: SupportTask) {
return supportAgent
.session({ userId, deviceId, sessionId: taskId })
.run(async () => {
const loop = new AmplitudeToolLoop({ amplitudeAI: ai, userId, sessionId: taskId });
const responses = await loop.run({
client: claude,
model: MODEL,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: prompt }],
tools,
toolExecutor: executeTool,
});
// run() resolves with every raw Claude response; the last one holds the answer.
const last = responses[responses.length - 1] as unknown as Anthropic.Message | undefined;
if (!last || last.stop_reason === 'tool_use') {
throw new Error(`Agent stopped before answering task ${taskId}`);
}
return last.content
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
});
}Why. The session gives the loop's events the agent ID and the device ID, and run() closes the session when the callback returns, so the task is scored without waiting for the 30-minute idle timeout. Don't call s.trackUserMessage() here: the loop records the [Agent] User Message from the messages you pass it, and a second call counts the question twice.
Pass only the messages for this task. The loop records a User Message for every user text message in the array on each run() call, so resending a long chat history records old questions again. Start a new task ID when the user starts a new job, rather than continuing the old session.
Use the same user ID your product events carry, and omit it when the user is signed out. Never send a placeholder like "anonymous"; Amplitude can't merge a placeholder into the real user later.
MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing taxonomy before it adds anything, so the product events your app already sends and the [Agent] events from this page end up in charts together instead of next to near-duplicates. Set up the MCP server.
5. Keep cost right with prompt caching
Anthropic reports cached tokens separately: input_tokens excludes cache_read_input_tokens and cache_creation_input_tokens. Amplitude expects inputTokens to include them, with the cache counts passed alongside so each is priced at its own rate. The loop and Amplitude's Anthropic wrapper both add them up for you. If you call Claude yourself and track the response with s.trackAiMessage(), do the same arithmetic, or cache-heavy calls get priced wrong.
// src/lib/claude-usage.ts
import type Anthropic from '@anthropic-ai/sdk';
// Only needed for Claude calls you track yourself with s.trackAiMessage().
export function claudeTokenCounts(usage: Anthropic.Usage) {
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
const cacheCreationTokens = usage.cache_creation_input_tokens ?? 0;
return {
inputTokens: usage.input_tokens + cacheReadTokens + cacheCreationTokens,
outputTokens: usage.output_tokens,
cacheReadTokens,
cacheCreationTokens,
};
}Use it as s.trackAiMessage(text, MODEL, 'anthropic', latencyMs, claudeTokenCounts(response.usage)). Do not add reasoning tokens separately; the deprecated reasoningTokens parameter is ignored.
If you want Claude's thinking recorded on [Agent] AI Response, make that call through the Anthropic wrapper from @amplitude/ai, which captures reasoning content along with streaming, tokens, and cost. The loop records the text of each response.
6. Flush before the process exits
The SDK batches events. Use flushAgentEvents() before a script exits or a serverless invocation returns. For a long-running server, call it after active requests finish during shutdown.
// src/index.ts
import { randomUUID } from 'node:crypto';
import { ai, flushAgentEvents } from './lib/amplitude.js';
import { runSupportAgent } from './agent.js';
const prompt = process.argv[2] ?? 'Where is order 1042, and can I still return it?';
try {
const answer = await runSupportAgent({
taskId: randomUUID(),
prompt,
userId: 'user-42', // use your signed-in user's ID
});
console.log(answer);
} finally {
await flushAgentEvents();
}For a long-running server, add await flushAgentEvents() to its existing shutdown sequence after it stops accepting new requests and drains active work.
Why. Without the flush, the script exits or the deploy stops the server with the last batch of agent events in memory, and nothing reports the loss. flushAgentEvents() waits for the underlying analytics upload attempt to finish. In a server, drain active requests before running the flush-and-exit handler.
Verify it worked
Run the script with a question that needs both tools:
pnpm exec tsx --env-file=.env src/index.ts "Where is order 1042, and can I still return it?"Then check in this order:
- Terminal. With
debugon outside production, every tracked event prints to stderr as a one-line summary, for example[amplitude-ai] [Agent] AI Response | user=user-42 session=... agent=support-agent model=claude-sonnet-5-5 .... Expect a User Message, an AI Response that asks for tools, a Tool Call for each tool Claude used, and a final AI Response, followed by the answer on stdout. You can also runpnpm exec amplitude-ai doctor. - Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show the script's uploads. It shows counts, not individual events.
- User lookup. Go to Users > User Profiles and search
user-42. Within ten seconds to a minute the event stream shows[Agent] User Message,[Agent] AI Response, and[Agent] Tool Call. Open an AI Response and confirm[Agent] Model Name(claude-sonnet-5-5),[Agent] Provider(anthropic),[Agent] Input Tokens,[Agent] Output Tokens,[Agent] Cost USD,[Agent] Latency Ms,[Agent] Session ID(the task ID), and[Agent] Agent ID(support-agent). Open a Tool Call and confirm[Agent] Tool Name,[Agent] Tool Success,[Agent] Tool Input, and[Agent] Tool Output. - Agent analytics. Open agent analytics in your project and find the session by its task ID. The
[Agent] Session Recordwith quality signals typically arrives within 15 to 20 minutes after the session closes.
Troubleshooting
Every run fails with a 400 error about max_tokens
The loop got a client that doesn't set max_tokens. Pass claude from src/lib/amplitude.ts, not the bare Anthropic client.
Each AI Response shows up twice
The loop got the Anthropic wrapper from @amplitude/ai, or patch() is on, so the wrapper and the loop both record every call. Give the loop the plain client from @anthropic-ai/sdk.
User Messages repeat earlier questions
You passed the whole chat history to loop.run(). The loop records every user text message in the array, so pass the messages for the current task and give each new job its own task ID.
[Agent] Tool Output reads [object Object]
The executor returned an object. Return JSON.stringify(result).
The run throws "Agent stopped before answering"
The loop stops after a fixed number of model calls, and Claude was still asking for tools. Check the Tool Calls in that session for a tool that keeps failing, and tighten its description so Claude stops retrying it.
[Agent] Cost USD is empty
The model name isn't in the SDK's bundled price catalog, which happens with gateway aliases, fine-tuned models, and models newer than your SDK version. Pass the canonical model ID. For a new model, import enableLivePriceUpdates from @amplitude/ai and call it once at startup if outbound HTTPS to the public price catalog is allowed. A failed refresh falls back to the bundled catalog. [Agent] Session Cost USD is all-or-nothing, so one unpriced response leaves it off the Session Record.
The last events never arrive
The process exited with events still buffered. Confirm src/index.ts awaits flushAgentEvents() in finally, and that your server drains active requests and awaits the same helper before exiting.
FAQ
Can I keep conversation text out of Amplitude?
Yes. Set the privacy mode to metadata_only. Tokens, cost, latency, models, and session grouping remain available; message text and tool payloads stay out. Built-in quality signals that need message text will not run.
Should every message get a new agent session ID?
Use one stable ID for the same conversation or task, and a new ID for a new job. Never reuse an ID across users. These scope helpers close at the end of each request or task; later events with the same ID are stored but do not update the first Session Record.
Why are events visible before quality signals?
Events normally appear within about a minute. The Session Record typically follows 15 to 20 minutes after an explicit close, and can take longer during an enrichment backlog.
Full code reference
Every file this tutorial adds, in one block, the version to hand to an agent. src/lib/claude-usage.ts is only needed if you track Claude calls yourself.
// -- .env --
// AMPLITUDE_AI_API_KEY=your-amplitude-project-api-key
// ANTHROPIC_API_KEY=your-anthropic-api-key
// -- src/lib/amplitude.ts --
import Anthropic from '@anthropic-ai/sdk';
import { AIConfig, AmplitudeAI } from '@amplitude/ai';
const apiKey = process.env.AMPLITUDE_AI_API_KEY;
if (!apiKey) {
throw new Error('AMPLITUDE_AI_API_KEY is not set');
}
export const ai = new AmplitudeAI({
apiKey,
config: new AIConfig({
contentMode: 'full',
redactPii: true,
debug: process.env.NODE_ENV !== 'production',
}),
});
export const supportAgent = ai.agent('support-agent', {
description: 'Answers order and help-center questions with tools',
});
// Wait for a promise-returning client or the analytics return object's promise.
export async function flushAgentEvents(): Promise<void> {
const pending = ai.flush();
if (pending && typeof pending === 'object' && 'promise' in pending) {
await pending.promise;
} else {
await pending;
}
}
export const MODEL = 'claude-sonnet-5-5';
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
type LoopRequest = Omit<Anthropic.MessageCreateParamsNonStreaming, 'max_tokens'> & {
max_tokens?: number;
};
export const claude = {
messages: {
create: (params: LoopRequest) =>
anthropic.messages.create({ ...params, max_tokens: params.max_tokens ?? 4096 }),
},
};
// -- src/tools.ts --
import type Anthropic from '@anthropic-ai/sdk';
export const tools = [
{
name: 'get_order_status',
description:
'Looks up the shipping status of one order by its order number. Use it when the user asks where an order is, when it ships, or whether it arrived. Returns the status, carrier, and estimated delivery date. It does not return payment or refund details.',
input_schema: {
type: 'object',
properties: {
order_id: { type: 'string', description: 'The order number, for example 1042.' },
},
required: ['order_id'],
},
},
{
name: 'search_help_docs',
description:
'Searches the help center for articles about returns, refunds, shipping, and account settings. Use it for policy questions the order record cannot answer. Returns up to three matching articles with a title and body.',
input_schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'A short search phrase, for example "return window".' },
},
required: ['query'],
},
},
] satisfies Anthropic.Tool[];
async function getOrderStatus(orderId: string) {
return { order_id: orderId, status: 'shipped', carrier: 'UPS', estimated_delivery: '2026-10-18' };
}
async function searchHelpDocs(query: string) {
return [{ title: 'Return an item', body: 'Start a return from Orders within 30 days of delivery.' }];
}
export async function executeTool(name: string, input: Record<string, unknown>): Promise<string> {
switch (name) {
case 'get_order_status':
return JSON.stringify(await getOrderStatus(String(input.order_id)));
case 'search_help_docs':
return JSON.stringify(await searchHelpDocs(String(input.query)));
default:
throw new Error(`Unknown tool: ${name}`);
}
}
// -- src/agent.ts --
import type Anthropic from '@anthropic-ai/sdk';
import { AmplitudeToolLoop } from '@amplitude/ai';
import { ai, claude, MODEL, supportAgent } from './lib/amplitude.js';
import { executeTool, tools } from './tools.js';
const SYSTEM_PROMPT =
'You are the support agent for an online store. Use the tools to look up orders and help articles. Answer in two or three sentences.';
export interface SupportTask {
taskId: string;
prompt: string;
userId?: string;
deviceId?: string;
}
export async function runSupportAgent({ taskId, prompt, userId, deviceId }: SupportTask) {
return supportAgent
.session({ userId, deviceId, sessionId: taskId })
.run(async () => {
const loop = new AmplitudeToolLoop({ amplitudeAI: ai, userId, sessionId: taskId });
const responses = await loop.run({
client: claude,
model: MODEL,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: prompt }],
tools,
toolExecutor: executeTool,
});
const last = responses[responses.length - 1] as unknown as Anthropic.Message | undefined;
if (!last || last.stop_reason === 'tool_use') {
throw new Error(`Agent stopped before answering task ${taskId}`);
}
return last.content
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
});
}
// -- src/lib/claude-usage.ts (optional) --
import type Anthropic from '@anthropic-ai/sdk';
export function claudeTokenCounts(usage: Anthropic.Usage) {
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
const cacheCreationTokens = usage.cache_creation_input_tokens ?? 0;
return {
inputTokens: usage.input_tokens + cacheReadTokens + cacheCreationTokens,
outputTokens: usage.output_tokens,
cacheReadTokens,
cacheCreationTokens,
};
}
// -- src/index.ts --
import { randomUUID } from 'node:crypto';
import { ai, flushAgentEvents } from './lib/amplitude.js';
import { runSupportAgent } from './agent.js';
const prompt = process.argv[2] ?? 'Where is order 1042, and can I still return it?';
try {
const answer = await runSupportAgent({
taskId: randomUUID(),
prompt,
userId: 'user-42',
});
console.log(answer);
} finally {
await flushAgentEvents();
}Hand it to your agent
Prefer to have Cursor, Claude Code, or Codex CLI do the wiring? This prompt points the agent at this page and pins the decisions that matter.
Add Amplitude agent analytics to this Claude tool-use agent by following
https://amplitude.com/build/anthropic-agent-analytics exactly. Install
@amplitude/ai, @amplitude/analytics-node, and @anthropic-ai/sdk. Create
src/lib/amplitude.ts that constructs AmplitudeAI from
process.env.AMPLITUDE_AI_API_KEY, the agent with ai.agent(), and a plain
Anthropic client from @anthropic-ai/sdk, all at module scope; never call
ai.agent() per request. Give AmplitudeToolLoop a client adapter whose
messages.create sets max_tokens, and never the Anthropic wrapper from
@amplitude/ai. Open agent.session({ userId, deviceId, sessionId }) with
the task's existing ID as sessionId and run loop.run() inside run().
Pass only the current task's messages, don't call trackUserMessage
yourself, and make the tool executor return strings and throw on
failure. Use the canonical model ID. Await flushAgentEvents() before a script
exits; in a server drain active requests, await flushAgentEvents(), then exit. Never pass a placeholder
userId. Don't hardcode keys. Report every file you changed.What this unlocks
- Chart
[Agent] Cost USDand[Agent] Latency Msby model on AI Response events. - Connect agent and product events in funnels when they use the same stable user ID.
- Review content-based quality signals on the Session Record after the session closes, when your privacy mode sends message text.