Runtime Lifecycle

The full timeline of a single agent.stream() call — 14 callback firing points across three layers, two-layer same-named callback comparison, stopWhen / timeout default chains

At a glance

A single agent.stream() call involves 3 API layers, 14 callbacks, 4 message chains, and 2 pairs of same-named callbacks. This page pins each one to the timeline.

Key numberValue
Total callbacks14 (deduplicated)
Same-named callback pairs2 (onStepEnd × 2 / onEnd × 2)
stopWhen default (L1)isStepCount(20)
stopWhen default (L2 streamText)isStepCount(1)
Timeout tiers3 (totalMs / stepMs / chunkMs)
Pinned SDK versionai@7.0.8

Three-layer capability matrix

Build the map before reading the timeline — for every parameter/callback, know which layer it belongs to:

L1: new ToolLoopAgent({...})L2: agent.stream({...})L3: result.toUIMessageStream({...})
RoleStatic config (define agent)Single invocation (trigger one run)Downstream consumer (transform result to UI stream)
LifecycleConstructed once, reusedCalled once per invocationCalled once per invocation
Structural paramsid, model, instructions, tools, runtimeContext, toolsContext, providerOptionsmessages / prompt, abortSignal, timeoutoriginalMessages, generateMessageId, sendReasoning, sendSources, sendStart, sendFinish
Behavior hooksstopWhen, prepareStep, prepareCallexperimental_transform
Callbacks (firing order)onStartprepareSteponStepStartonLanguageModelCallStartonLanguageModelCallEndonToolExecutionStartonToolExecutionEndonStepEndonEndSame as L1 (merged with L1 same-named callbacks — both fire in parallel)messageMetadataonStepEndonEndonError

L1 and L2 same-named callbacks are merged: if both layers set onStepEnd, they fire in parallel — v7 wraps them with mergeCallbacks, which awaits both via Promise.allSettled (source: agent/tool-loop-agent.ts:285-315, util/merge-callbacks.ts:16; v6 fired L1 then L2 sequentially). L3’s same-named callbacks are entirely independent — they don’t merge with L1/L2 and have different payloads. (v7 renamed the canonical callbacks onStepFinishonStepEnd and onFinishonEnd at both the L1/L2 and L3 layers; the v6 names live on as deprecated aliases, resolved via a ?? fallback.)

L3’s other entry point: this page’s L3 column focuses on the toUIMessageStream({ stream }) transform path (passively consuming the agent’s result). v7 canonical is the standalone toUIMessageStream({ stream: result.stream }) helper; the result.toUIMessageStream() method is a deprecated alias. L3 has another entry point — createUIMessageStream({ execute }) — the execute-driven path, which can actively push custom events and merge multiple streams alongside the agent’s. Both share the same underlying handleUIMessageStreamFinish (ui-message-stream/handle-ui-message-stream-finish.ts), so onStepEnd / onEnd / onError fire at the same moments described on this page; but messageMetadata exists only on toUIMessageStream. The execute-driven path is covered in detail in UI Stream Orchestration.

Full timeline diagram

A single N-step agent.stream() call, time flows downward:

agent.stream() Full Lifecycle 3-layer API · 12 callbacks · 29 signals on one timeline Caller your code ToolLoopAgent L1 — settings streamText L2 — loop toUIMessageStream L3 — pipe new ToolLoopAgent({ ... }) Stores settings — no callbacks fire agent.stream({ messages, ... }) prepareCall(baseCallArgs) Called once per invocation (rare hook) streamText(mergedArgs) experimental_onStart() Once, globally loop — N steps stepInputMessages = [...initialMessages, ...responseMessages] prepareStep({ messages, steps, stepNumber, model }) May return { messages, system, model, toolChoice, activeTools, context } experimental_onStepStart() Model stream begins "start-step" chunk "start-step" text-delta / reasoning-delta / ... loop — per tool call experimental_onToolCallStart() tool.execute(input, { abortSignal, context, messages, toolCallId }) experimental_onToolCallFinish() "finish-step" chunk "finish-step" onStepFinish(stepResult) [L1/L2] payload: stepNumber, content, toolCalls, toolResults, finishReason, usage, response each chunk through transform messageMetadata({ part }) Per part — hot path, no I/O "finish-step" chunk passes onStepFinish [L3] responseMessage, messages, isContinuation isStopConditionMet? break or continue "finish" chunk aggregate totalUsage "finish" onFinish({ ... }) [L1/L2] payload: finishReason, totalUsage, steps, content, response, request, warnings fullStream closes flush() onFinish({ ... }) [L3] payload: responseMessage, messages, isContinuation, isAborted, finishReason opt — on any error onError(error)

Three critical observations:

  1. L1/L2 callbacks and L3 callbacks run concurrently — L2 pushes chunks to stream while L3’s pipe transforms them. So L1/L2 onStepEnd(n) and L3 onStepEnd(n) happen nearly simultaneously, but as independent event-loop tasks.
  2. L3 onEnd always fires later than L1/L2 onEnd — L3 is a downstream transform, it must wait for stream close + consumer drain before flushing. For “after-run” work: use L1/L2 onEnd for engine-side cleanup (token tallying, sandbox close), L3 onEnd for UI-side persistence (saving the assistant message).
  3. messageMetadata runs per chunk — including every text-delta and tool-input-delta. A long multi-tool response can easily emit 1000+ chunks; any synchronous I/O here directly stalls the stream.

Callback firing reference

In firing order. L1 = ToolLoopAgent settings, L2 = streamText (direct pass-through from agent.stream), L3 = toUIMessageStream.

#CallbackLayerWhenPayloadUse for
1prepareCall(baseCallArgs)L1Before each agent.stream() starts, after params mergeFull call args, returns overridesDynamic model/tools/stopWhen rewrite
2onStart()L1+L2After streamText starts, before first stepNoneInit logging/timing
3prepareStep({...})L1+L2Before each step’s model call{ messages, steps, stepNumber, model }Compaction, reminder injection, activeTools filtering, model switching
4onStepStart()L1+L2Before each step’s model stream (after prepareStep)NonePer-step timing marker
5onLanguageModelCallStart()L1+L2Before each provider model call begins{ ... } (call metadata)Provider-call timing marker, request logging
6onLanguageModelCallEnd()L1+L2After each provider model call response is normalized{ ... } (call result)Provider-call latency, raw-response observability
7onToolExecutionStart()L1+L2Before each tool.execute{ toolCall }Permission audit, pre-retry logic
8onToolExecutionEnd()L1+L2After each tool.execute{ toolCall, toolResult }Observability, cache writeback
9onStepEnd(stepResult)L1+L2Each step end, after finish-step chunk emittedStepResult: full step detailToken tallying, step-level persistence
10messageMetadata({ part })L3Each chunk passing through UI transform{ part } (current chunk)Attach metadata to UI control chunks
11onStepEndL3Each finish-step chunk passing through UI transform{ responseMessage, messages, isContinuation }UI-side step-level persistence
12onEnd({...})L1+L2After all steps done, after finish chunk emitted{ finishReason, usage, steps, ... }Engine-side settlement, cleanup
13onEnd({...})L3After UI stream drain / cancel{ responseMessage, messages, isContinuation, isAborted, finishReason }UI-side message persistence
14onError(error)L3UI transform error / error chunk / onStepEnd throwError or stringSSE error serialization; the returned string is written into the error chunk’s errorText field sent to the client

onEnd throws do NOT route here: the onEnd call (ui-message-stream/handle-ui-message-stream-finish.tscallOnEnd, ~L120-137) is a bare await with no try/catch. An onEnd throw propagates out through TransformStream’s flush(), rejecting the consumer iterator — it does not invoke onError. Any production onEnd must wrap its own try/catch/finally inside the callback. Full error-capture tiering in UI Stream Orchestration — Error capture, in full.

Same-named callbacks — the biggest trap

These are the v7 canonical names onStepEnd / onEnd (v6: onStepFinish / onFinish, still accepted as deprecated aliases). The “two same-named callbacks fire twice” trap is unchanged — it’s just that the shared name at both layers is now onEnd (and onStepEnd).

onStepEnd: L1/L2 vs L3

L1/L2 (streamText)L3 (UI stream)
WhenStep loop ends, after finish-step emitfinish-step chunk through UI transform
PayloadStepResult: { stepNumber, content, text, toolCalls, toolResults, finishReason, usage, response, request, ... }{ responseMessage: UIMessage, messages: UIMessage[], isContinuation }
What you seeEngine view: raw step output (tool call objects, usage breakdown)Consumer view: accumulated UI message (assistant message structure)
Use forToken tallying (billing), step-level logging, driving compaction / context trimmingIncremental UI message persistence, prefetching

onEnd: L1/L2 vs L3

L1/L2 (streamText)L3 (UI stream)
WhenAfter finish chunk emit, before stream closeAfter stream close + UI transform flush
Payload{ finishReason, usage, steps, content, text, reasoningText, toolCalls, toolResults, response, request, warnings, providerMetadata }{ responseMessage, messages, isContinuation, isAborted, finishReason }
OrderingEarlier (upstream)Later (downstream drain)
Use forEngine-level one-shot settlement: write total usage to DB, close sandbox, commit compaction checkpointUI-level one-shot settlement: persist final assistant message, notify client of completion

L1/L2 onEnd closure trap: the L1/L2 onEnd payload carries the entire steps array — every step’s full StepResult (content, toolCalls, toolResults, request, response, all of it). If your callback closure captures this payload and pins it on a long-lived reference (e.g. storing it on an outer session object), the entire large object graph from this invocation is held and never GC’d. Long chat conversations amplify this — 20 steps of cumulative StepResult easily reaches hundreds of MB.

Practical guidance:

  • Short settlement logic (token counting, step logs) can live in L1/L2 onEnd — closure releases right after the callback returns
  • Long settlement logic (persistence, background jobs, checkpoint writes) should prefer L3 onEnd; its payload is the folded responseMessage + messages, orders of magnitude smaller
  • Or: use L1/L2 onStepEnd to incrementally collect only what you need into a small variable (numbers / strings / ids only, never the StepResult itself), then settle on that small variable in L3 onEnd

stopWhen default chain — the second trap

Same parameter name, different defaults at L1 and L2:

L1 new ToolLoopAgent({ stopWhen? })  default: isStepCount(20)   ← agent/tool-loop-agent.ts:132
L2 streamText({ stopWhen? })         default: isStepCount(1)    ← generate-text/stream-text.ts:347

ToolLoopAgent.stream() forwards L1’s stopWhen (default 20) to streamText, so the normal path caps the agent at 20 steps.

But if you call streamText(...) directly without setting stopWhen, your agent runs for exactly one step — one tool call and it halts. Classic beginner trap.

Built-in stop conditions (combinable: stopWhen: [isStepCount(N), hasToolCall('complete')]):

FactorySemantics
isStepCount(N)Stop after reaching step N
hasToolCall(name)Stop after a tool call with the given name

Typical production combo: [isStepCount(N), hasToolCall('complete')] — the number is a hard ceiling (prevents the agent from spinning in a loop), hasToolCall('complete') is the “task finished” signal (lets the agent declare its own end). Pick N based on task complexity: simple Q&A 10-20, general assistant 30-50, deep research / multi-step editing 50-100.

Three-tier timeout — the third trap

timeout is an object with three granularities:

agent.stream({
  timeout: {
    totalMs: 600_000, // Entire invocation: 10 min
    stepMs: 300_000, // Single step: 5 min (model + tools combined)
    chunkMs: 120_000, // Gap between two chunks: 2 min
  },
});

All three are independent timers, but v7 folds them into one abort signal via mergeAbortSignals(...) (generate-text/stream-text.ts:732-737) — any one tripping aborts the whole stream, and the merged signal is also handed to the running tool. The tool-execution consequences of chunkMs specifically (why it can abort a long silent tool, and the heartbeat fix) are covered in Stream Timeouts & Tool Heartbeat.

DimensionWatches forTypical scenario
totalMsAbsolute call durationLong-task total ceiling
stepMsSingle step from prepareStep to finish-stepModel response stalls
chunkMsGap between two adjacent chunksMid-stream hang (TCP half-open, slow provider thinking phase)

Tutorials usually only mention totalMs — but chunkMs is the lifesaver in production. A model stream can emit two lines then hang (TCP half-open from the provider, a thinking phase taking too long, etc.); totalMs is nowhere near, but chunkMs terminates it immediately. A solid default in production is chunkMs: 120_000 (2 minutes) — enough to cover long reasoning-model thinking gaps, but not so long that a real disconnect quietly hangs forever.

prepareCall — the little-known upstream hook

Besides prepareStep (per-step), L1 also has prepareCall (per-invocation):

new ToolLoopAgent({
  model,
  instructions,
  tools,
  prepareCall: async (baseCallArgs) => {
    // baseCallArgs = all merged args (settings + method options)
    // Return overrides — or return nothing to use baseCallArgs as-is
    return {
      ...baseCallArgs,
      tools: dynamicallyDecideTools(baseCallArgs.messages),
      stopWhen: isStepCount(deriveStepLimit(user)),
    };
  },
});

When to use:

  • Dynamically swap tool sets (user tier / A-B test) without rebuilding the agent instance
  • Pick a model based on the call’s input
  • Set stopWhen per call

prepareCall vs prepareStep:

prepareCallprepareStep
LayerL1L1 or L2
Invocation countOnce per agent.stream()Once per step (N times per invocation)
Can overrideAll call args (tools, stopWhen, instructions, messages, model, …)Per-step args (messages, system, model, toolChoice, activeTools)
Use forStatic config dynamizationRuntime context adaptation (compaction, reminders, dynamic tool sets)

Picking between them: most projects only need prepareStep — runtime compaction, per-step reminder injection, swapping tool sets based on conversation length, these are all per-step scenarios. prepareCall makes more sense for deployments where the same agent instance is reused across requests (the agent is constructed once at module top level, and each HTTP request rewrites call args based on user tier / AB experiments). If your agent is reconstructed per request (the orchestrator layer already does config dynamization), prepareCall is redundant.

The four step/tool lifecycle hooks

In v7 these are canonical — no experimental_ prefix. The v6 names (experimental_onStart, experimental_onStepStart, experimental_onToolCallStart, experimental_onToolCallFinish) remain as deprecated aliases, resolved via a ?? fallback in generate-text/stream-text.ts:718-724. Note the rename of the tool pair: onToolCall{Start,Finish}onToolExecution{Start,End}. (v7 also promoted onLanguageModelCallStart / onLanguageModelCallEnd out of experimental_ — the provider-call pair added to the timeline above.) They’re core building blocks for observable agents:

HookUse forTypical landing scenarios
onStartCall-level “begin” markerEmit a UI init event, start the whole-call timer, log run start
onStepStartStep-level “begin” markerReset step counter, clear per-step buffer, start per-step timer
onToolExecutionStartTool-level “begin” markerAudit log, permission check, blocking validation
onToolExecutionEndTool-level “done” markerResult caching, metric reporting, tool-level error routing

Difference from onStepEnd: onStepEnd is the aggregation callback at step end (full StepResult); onStepStart is the transition point at step start (no payload, pure signal). For “inter-step cleanup” use the former; for “step initialization” use the latter.

Further reading

Related SDK chapters

SDK source anchors (ai@7.0.8, paths under src/)

  • generate-text/stream-text.ts:330-787streamText entry
  • generate-text/stream-text.ts:1306-1309onStepEnd emission (var still named onStepFinish internally)
  • agent/tool-loop-agent.ts:39-323ToolLoopAgent implementation
  • ui-message-stream/to-ui-message-stream.ts:18-91toUIMessageStream implementation

Zapvol landing reference

  • packages/backend/src/agent/agent-loop.ts — the assembly point for all-layer callbacks (ToolLoopAgent construction, prepareStep, onStepEnd, stopWhen: config.stopConditions, stepUsages incremental collection)
  • packages/backend/src/agent/agent-ui-stream.ts — the L3 toUIMessageStream params
  • apps/server/src/services/task-orchestrator.ts (and chat-orchestrator.ts) — L3 onEnd used for assistant message persistence
Was this page helpful?