Lifecycle Callbacks

Event callbacks let you run your own code at important points in an AI SDK call. You can attach them directly to generateText, streamText, embed, embedMany, and rerank calls to observe what happened, record usage, debug multi-step generations, and monitor tool execution.

They are especially useful when you want application-specific logic close to the call site:

  • Log which model, prompt shape, and settings were used for a request.
  • Record token usage, latency, finish reasons, and warnings for analytics or billing.
  • Understand how a multi-step tool call moved from model response to tool execution to final answer.
  • Track tool inputs, tool outputs, execution time, and errors.
  • Attach your own request, user, tenant, or workflow identifiers through runtimeContext and toolsContext.

For automatic OpenTelemetry instrumentation across your application, use Telemetry. Use event callbacks when you want to run custom code for a specific AI SDK call.

Basic Usage

Pass callbacks as options to the AI SDK function you are calling:

import { generateText } from 'ai';
const result = await generateText({
model: "xai/grok-4.5",
prompt: 'What is the weather in San Francisco?',
onStart({ callId, modelId }) {
console.log('Generation started', { callId, modelId });
},
onEnd({ callId, usage, finishReason }) {
console.log('Generation finished', {
callId,
finishReason,
totalTokens: usage.totalTokens,
});
},
});

Callbacks can be synchronous or asynchronous. If a callback throws, the error is caught internally and the AI SDK call continues. Because callbacks run as part of the lifecycle, keep them fast or enqueue expensive work in a background system.

Use Cases

Request Logging

Use onStart and onEnd to record one application log for the beginning and end of a call. The callId is available across lifecycle events, so you can correlate logs from the same request.

import { generateText } from 'ai';
const result = await generateText({
model: "xai/grok-4.5",
prompt: 'Write a short product description for a camping mug.',
onStart({ callId, provider, modelId }) {
logger.info('ai.request.started', {
callId,
provider,
modelId,
});
},
onEnd({ callId, finishReason, usage, warnings }) {
logger.info('ai.request.finished', {
callId,
finishReason,
usage,
warningCount: warnings?.length ?? 0,
});
},
});

This pattern works well for audit logs, internal dashboards, and tracking usage for a particular feature.

Measuring Model Performance

onLanguageModelCallEnd runs after a provider response has been normalized and parsed. For streamText, the event also includes streaming-specific timing data such as time to first output and gaps between output chunks.

import { streamText } from 'ai';
const result = streamText({
model: "xai/grok-4.5",
prompt: 'Explain partial prerendering in two paragraphs.',
onLanguageModelCallEnd({ callId, modelId, usage, performance }) {
metrics.histogram('ai.model.response_time_ms', performance.responseTimeMs, {
callId,
modelId,
});
metrics.gauge('ai.model.tokens_per_second', {
output: performance.outputTokensPerSecond,
total: performance.effectiveTotalTokensPerSecond,
tokens: usage.totalTokens,
});
},
});
for await (const textPart of result.textStream) {
process.stdout.write(textPart);
}

Use model-call events when you want to measure provider work specifically. Use step events when you want timing that includes SDK-managed work such as local tool execution.

Debugging Multi-Step Tool Calls

When you use tools with generateText or streamText, a single user request can involve multiple model calls. Each model call is a step. The model may call a tool in one step, receive the tool result, and then produce a final answer in the next step.

import { generateText, isStepCount, tool } from 'ai';
import { z } from 'zod';
const result = await generateText({
model: "xai/grok-4.5",
stopWhen: isStepCount(5),
prompt: 'What is the weather in San Francisco?',
tools: {
weather: tool({
description: 'Get the weather in a location',
inputSchema: z.object({ location: z.string() }),
execute: async ({ location }) => getWeather(location),
}),
},
onStepStart({ stepNumber, messages, steps }) {
console.log(`Step ${stepNumber} started`, {
messageCount: messages.length,
previousSteps: steps.length,
});
},
onStepEnd({ stepNumber, finishReason, toolCalls, usage, performance }) {
console.log(`Step ${stepNumber} finished`, {
finishReason,
toolCalls: toolCalls.map(toolCall => toolCall.toolName),
totalTokens: usage.totalTokens,
stepTimeMs: performance.stepTimeMs,
});
},
});

This helps answer questions such as:

  • Did the model call a tool or answer directly?
  • How many steps did the request take?
  • Which step used the most tokens?
  • Did time go to the model response or to local tool execution?

Monitoring Tool Execution

Tool execution callbacks run around the tool's execute function. Use them to record tool usage, latency, successful results, and tool errors.

import { generateText, tool } from 'ai';
import { z } from 'zod';
const result = await generateText({
model: "xai/grok-4.5",
prompt: 'Find flights from SFO to JFK tomorrow morning.',
tools: {
searchFlights: tool({
description: 'Search available flights',
inputSchema: z.object({
origin: z.string(),
destination: z.string(),
}),
execute: async input => searchFlights(input),
}),
},
onToolExecutionStart({ callId, toolCall }) {
logger.info('ai.tool.started', {
callId,
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
input: toolCall.input,
});
},
onToolExecutionEnd({ callId, toolCall, toolExecutionMs, toolOutput }) {
logger.info('ai.tool.finished', {
callId,
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
durationMs: toolExecutionMs,
success: toolOutput.type === 'tool-result',
});
},
});

toolOutput is a discriminated union. When toolOutput.type is 'tool-result', the output is available on toolOutput.output. When it is 'tool-error', the error is available on toolOutput.error.

Observing Embeddings and Reranking

Embedding and reranking callbacks are simpler: they expose onStart and onEnd around the operation. This is useful for retrieval pipelines where you want to understand how often you are embedding, how many values you embed, and how reranking changes result sets.

import { embedMany, rerank } from 'ai';
import { cohere } from '@ai-sdk/cohere';
const values = ['sunny day at the beach', 'rainy afternoon in the city'];
const { embeddings } = await embedMany({
model: 'openai/text-embedding-3-small',
values,
onStart({ callId, operationId, modelId, value }) {
logger.info('ai.embedding.started', {
callId,
operationId,
modelId,
valueCount: Array.isArray(value) ? value.length : 1,
});
},
onEnd({ callId, usage }) {
logger.info('ai.embedding.finished', {
callId,
tokens: usage.tokens,
});
},
});
const { ranking } = await rerank({
model: cohere.reranking('rerank-v3.5'),
documents: values,
query: 'talk about rain',
onEnd({ callId, ranking }) {
logger.info('ai.rerank.finished', {
callId,
topResult: ranking[0],
});
},
});

Generation Lifecycle

generateText and streamText expose the richest lifecycle because they can involve prompts, model calls, tool calls, and multiple steps.

A typical single-step generation runs in this order:

  1. onStart
  2. onStepStart
  3. onLanguageModelCallStart
  4. onLanguageModelCallEnd
  5. onStepEnd
  6. onEnd

A multi-step generation with local tool execution usually runs like this:

  1. onStart
  2. onStepStart
  3. onLanguageModelCallStart
  4. onLanguageModelCallEnd
  5. onToolExecutionStart
  6. onToolExecutionEnd
  7. onStepEnd
  8. Repeat step callbacks until the stop condition is met
  9. onEnd

onStepStart and onStepEnd describe the full step. onLanguageModelCallStart and onLanguageModelCallEnd describe only the model call inside that step. This distinction matters when the step includes local tool execution: the step duration can be longer than the model response duration.

Runtime and Tool Context

Lifecycle callbacks receive the full runtimeContext and toolsContext values that flow through the call. This makes callbacks useful for attaching application context without changing prompts or tool inputs.

import { generateText, tool } from 'ai';
import { z } from 'zod';
const result = await generateText({
model: "xai/grok-4.5",
prompt: 'Check the order status.',
runtimeContext: {
requestId: 'req_123',
tenantId: 'tenant_abc',
},
tools: {
getOrderStatus: tool({
inputSchema: z.object({ orderId: z.string() }),
contextSchema: z.object({ region: z.string() }),
execute: async ({ orderId }, { context }) =>
getOrderStatus(orderId, context.region),
}),
},
toolsContext: {
getOrderStatus: {
region: 'us-east-1',
},
},
onStart({ callId, runtimeContext }) {
logger.info('ai.request.started', {
callId,
requestId: runtimeContext.requestId,
tenantId: runtimeContext.tenantId,
});
},
onToolExecutionStart({ toolCall, toolContext }) {
logger.info('ai.tool.started', {
toolName: toolCall.toolName,
region: toolContext.region,
});
},
});

Telemetry integrations can filter runtimeContext and toolsContext before exporting them. Lifecycle callbacks receive the full context objects. Be careful not to log secrets or sensitive user data from callbacks.

Available Callbacks

generateText and streamText

onStart:

(event: GenerateTextStartEvent) => void | Promise<void>

onStepStart:

(event: GenerateTextStepStartEvent) => void | Promise<void>

onLanguageModelCallStart:

(event: LanguageModelCallStartEvent) => void | Promise<void>

onLanguageModelCallEnd:

(event: LanguageModelCallEndEvent) => void | Promise<void>

onToolExecutionStart:

(event: ToolExecutionStartEvent) => void | Promise<void>

onToolExecutionEnd:

(event: ToolExecutionEndEvent) => void | Promise<void>

onStepEnd:

(event: GenerateTextStepEndEvent) => void | Promise<void>

onEnd:

(event: GenerateTextEndEvent) => void | Promise<void>
onStepFinish is deprecated. Use onStepEnd for new code.

embed and embedMany

onStart:

(event: EmbedStartEvent) => void | Promise<void>

onEnd:

(event: EmbedEndEvent) => void | Promise<void>

rerank

onStart:

(event: RerankStartEvent) => void | Promise<void>

onEnd:

(event: RerankEndEvent) => void | Promise<void>

Event Data Reference

The exact event data depends on the callback. The tables below summarize the fields you will most commonly use.

Text Generation Events

onStart

Called once before any model calls are made.

callId:

string

operationId:

string

provider:

string

modelId:

string

messages:

Array<ModelMessage>

instructions:

Instructions | undefined

tools:

ToolSet | undefined

toolChoice:

ToolChoice | undefined

activeTools:

ActiveTools<TOOLS>

maxRetries:

number

timeout:

TimeoutConfiguration | undefined

headers:

Record<string, string | undefined> | undefined

providerOptions:

ProviderOptions | undefined

runtimeContext:

CONTEXT

toolsContext:

InferToolSetContext<TOOLS>

onStepStart

Called before each step begins.

callId:

string

stepNumber:

number

provider:

string

modelId:

string

messages:

Array<ModelMessage>

tools:

ToolSet | undefined

activeTools:

ActiveTools<TOOLS>

steps:

ReadonlyArray<StepResult>

providerOptions:

ProviderOptions | undefined

runtimeContext:

CONTEXT

toolsContext:

InferToolSetContext<TOOLS>

onLanguageModelCallStart

Called immediately before the provider model call begins.

callId:

string

provider:

string

modelId:

string

instructions:

Instructions | undefined

messages:

Array<ModelMessage>

tools:

ReadonlyArray<Record<string, unknown>> | undefined

onLanguageModelCallEnd

Called after the provider response has been normalized and parsed, before local tool execution begins.

callId:

string

provider:

string

modelId:

string

finishReason:

FinishReason

usage:

LanguageModelUsage

content:

ReadonlyArray<ContentPart<TOOLS>>

responseId:

string

performance:

LanguageModelCallPerformance

onToolExecutionStart

Called before a local tool's execute function runs.

callId:

string

toolCall:

TypedToolCall

messages:

Array<ModelMessage>

toolContext:

InferToolContext<TOOLS[toolName]>

onToolExecutionEnd

Called after a local tool's execute function completes or errors.

callId:

string

toolCall:

TypedToolCall

toolExecutionMs:

number

messages:

Array<ModelMessage>

toolContext:

InferToolContext<TOOLS[toolName]>

toolOutput:

{ type: 'tool-result'; output: unknown } | { type: 'tool-error'; error: unknown }

onStepEnd

Called after each step completes. The event is the full StepResult for that step.

callId:

string

stepNumber:

number

model:

{ provider: string; modelId: string }

content:

Array<ContentPart>

text:

string

toolCalls:

Array<TypedToolCall>

toolResults:

Array<TypedToolResult>

finishReason:

FinishReason

usage:

LanguageModelUsage

performance:

StepResultPerformance

warnings:

CallWarning[] | undefined

request:

LanguageModelRequestMetadata

response:

LanguageModelResponseMetadata

runtimeContext:

CONTEXT

toolsContext:

InferToolSetContext<TOOLS>

onEnd

Called once when the full generation completes.

callId:

string

steps:

Array<StepResult>

finalStep:

StepResult

responseMessages:

Array<ResponseMessage>

content:

Array<ContentPart>

text:

string

toolCalls:

Array<TypedToolCall>

toolResults:

Array<TypedToolResult>

finishReason:

FinishReason

usage:

LanguageModelUsage

warnings:

CallWarning[] | undefined

Embedding Events

embed and embedMany share the same event interfaces. Use operationId to distinguish 'ai.embed' from 'ai.embedMany'. For embed, value is a single string. For embedMany, value is an array of strings.

onStart

callId:

string

operationId:

string

provider:

string

modelId:

string

value:

string | Array<string>

maxRetries:

number

headers:

Record<string, string | undefined> | undefined

providerOptions:

ProviderOptions | undefined

onEnd

callId:

string

operationId:

string

provider:

string

modelId:

string

value:

string | Array<string>

embedding:

Embedding | Array<Embedding>

usage:

EmbeddingModelUsage

warnings:

Array<Warning>

providerMetadata:

ProviderMetadata | undefined

response:

{ headers?: Record<string, string>; body?: unknown } | Array<{ headers?: Record<string, string>; body?: unknown } | undefined> | undefined

Rerank Events

onStart

callId:

string

operationId:

string

provider:

string

modelId:

string

documents:

Array<JSONObject | string>

query:

string

topN:

number | undefined

maxRetries:

number

headers:

Record<string, string | undefined> | undefined

providerOptions:

ProviderOptions | undefined

onEnd

callId:

string

operationId:

string

provider:

string

modelId:

string

documents:

Array<JSONObject | string>

query:

string

ranking:

Array<{ originalIndex: number; score: number; document: JSONObject | string }>

warnings:

Array<Warning>

providerMetadata:

ProviderMetadata | undefined

response:

{ id?: string; timestamp: Date; modelId: string; headers?: Record<string, string>; body?: unknown }