homeresume
 
   
🔍

Conversation memory for LangChain agents

June 18, 2026

This post extends the support triage agent from Building AI agents with LangChain into a multi-turn flow: turn 1 looks up the customer and invoice; turn 2 creates the ticket without the user repeating IDs. It is post #5 in the LangChain series, following the overview, loaders/chunking, RAG, and agents posts.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • Packages from the agents post, plus the checkpoint package:
npm i langchain @langchain/openai @langchain/core @langchain/langgraph-checkpoint zod
  • OPENAI_API_KEY set in the environment

Mental model

Three related concepts:

  • Checkpointer - short-term session memory. Saves messages and graph state after each step so the next invoke on the same thread can resume.
  • thread_id - conversation key passed in configurable. Same ID = same history; different ID = isolated session.
  • Store - long-term memory across threads (user preferences, facts learned over time). LangGraph stores are separate from checkpointers; this post focuses on checkpointers only.

Typical support flow with memory:

  1. Turn 1 - rep asks to look up cus_1042 and inv_8891; agent calls lookup tools and summarizes findings.
  2. Turn 2 - rep says "create the ticket we discussed"; agent recalls prior tool results and calls create_support_ticket.

MemorySaver

For demos and tests, use MemorySaver - an in-memory checkpointer that persists state for the lifetime of the process:

import { MemorySaver } from '@langchain/langgraph-checkpoint';
const checkpointer = new MemorySaver();

State is lost when the Node process exits. That is fine for local scripts; production apps need a durable backend (see below).

Attach a checkpointer to createAgent

Pass the checkpointer when creating the agent. Reuse the same triage tools and instructions from the agents post:

import { createAgent } from 'langchain';
import { MemorySaver } from '@langchain/langgraph-checkpoint';
const agent = createAgent({
model: 'gpt-5.5',
tools: supportTools,
systemPrompt: TRIAGE_INSTRUCTIONS,
checkpointer: new MemorySaver(),
});

The agent loop is unchanged - the checkpointer hooks into LangGraph beneath createAgent.

First turn - lookup

Pass a stable thread_id in the invoke config:

const threadConfig = { configurable: { thread_id: 'support-cus-1042' } };
const turn1 = await agent.invoke(
{
messages: [
{
role: 'user',
content:
'Look up customer cus_1042 and invoice inv_8891 for a possible duplicate charge. Summarize what you find. Do not create a ticket yet.',
},
],
},
threadConfig,
);
console.log(turn1.messages.at(-1)?.content);

The agent calls get_customer, get_invoice, and search_knowledge_base. LangGraph saves the full message history (including tool results) to the checkpointer.

Second turn - follow-up without IDs

Send only the new user message on the same thread_id. Prior context is restored automatically:

const turn2 = await agent.invoke(
{
messages: [
{
role: 'user',
content: 'Create the support ticket we discussed.',
},
],
},
threadConfig,
);
console.log(turn2.messages.at(-1)?.content);

The agent should call create_support_ticket using customer and invoice details from turn 1 - the user does not repeat cus_1042 or inv_8891.

Read the final answer from result.messages as in the agents post:

const lastAi = [...turn2.messages]
.reverse()
.find((message) => message.type === 'ai');
console.log(lastAi?.content);

Thread isolation

Different thread_id values do not share history. Two support reps working different cases should use separate thread IDs:

await agent.invoke(
{ messages: [{ role: 'user', content: 'Look up cus_1042.' }] },
{ configurable: { thread_id: 'rep-alice-case-1' } },
);
await agent.invoke(
{ messages: [{ role: 'user', content: 'Create the ticket we discussed.' }] },
{ configurable: { thread_id: 'rep-bob-case-2' } },
);

The second invoke on rep-bob-case-2 has no knowledge of Alice's lookup - Bob's thread starts empty.

Production checkpointers

MemorySaver is process-local and not suitable for production. LangGraph supports durable checkpointers backed by Postgres, SQLite, and other stores via @langchain/langgraph-checkpoint integrations. Swap the checkpointer implementation; the thread_id API stays the same.

Pick a backend that matches your deployment: Postgres for multi-instance apps, SQLite for single-node services.

Demo

See the langchain-agent-memory-nodejs-demo folder for multi-turn triage and thread-isolation scripts.

Building AI agents with LangChain

June 17, 2026

LangChain agents are built on LangGraph: the model calls tools in a loop until it returns a final answer. The high-level entry point is createAgent - pass a model, tools defined with tool(), and an optional systemPrompt.

This post builds the same support triage agent as the Vercel AI SDK agents and OpenAI Agents SDK posts so you can compare SDKs on one scenario. It follows the LangChain overview for Node.js and fits as post #4 in the LangChain series (after loaders/chunking and the RAG with pgvector pipeline).

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • langchain, @langchain/openai, @langchain/core, and zod installed:
npm i langchain @langchain/openai @langchain/core zod
  • OPENAI_API_KEY set in the environment

Mental model - turns and the agent loop

A turn is one model generation. In that turn the model either:

  • returns final text (the run ends), or
  • returns tool calls (LangChain executes them and starts another turn with the results)

Typical flow for the support triage agent: user question → model calls lookup tools (get_customer, get_invoice, search_knowledge_base) → model creates a ticket or escalates → final answer.

A single turn can include multiple parallel tool calls. Set recursionLimit on invoke or stream to cap how many graph steps run (each model generation and tool batch counts toward the limit).

Defining tools

Use tool() from langchain with a Zod schema, plus name and description so the model knows when to call each tool:

import { tool } from 'langchain';
import { z } from 'zod';
const getInvoice = tool(
async ({ invoiceId }) => {
const invoice = invoices.find((item) => item.id === invoiceId);
if (!invoice) {
return { found: false, invoiceId, error: 'Invoice not found' };
}
return { found: true, invoice };
},
{
name: 'get_invoice',
description: 'Look up an invoice by ID, including payment IDs and status',
schema: z.object({
invoiceId: z.string().describe('Invoice ID, e.g. inv_8891'),
}),
},
);

LangChain uses schema (not Vercel's inputSchema or OpenAI Agents' parameters). The handler receives validated input as the first argument.

createAgent

Wire the model, tools, and triage instructions:

import { createAgent } from 'langchain';
const agent = createAgent({
model: 'gpt-5.5',
tools: [getInvoice],
systemPrompt: `You are a billing support triage agent.
Look up records before recommending refunds or creating tickets.`,
});

model can be a provider string ('gpt-5.5', 'openai:gpt-5.5') or a chat model instance from @langchain/openai.

Invoke

Pass a messages array and read the final answer from result.messages:

const result = await agent.invoke({
messages: [
{
role: 'user',
content: 'What is the status of invoice inv_8891? Reply in one sentence.',
},
],
});
const lastAi = [...result.messages]
.reverse()
.find((message) => message.type === 'ai');
console.log(lastAi?.content);

The last AI message is the agent's final reply after any tool calls complete.

Support triage scenario

Example prompt:

Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?

A realistic chain:

  1. get_customer - plan tier, open ticket count
  2. get_invoice - amount, status, payment IDs
  3. search_knowledge_base - duplicate-charge and refund policy
  4. create_support_ticket or escalate_to_human - write action or escalation

The demo uses in-memory fixtures (customers, invoices, knowledge-base articles) so scripts run without a database.

Multi-tool agent

Register all triage tools on one agent:

import { createAgent } from 'langchain';
import {
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
TRIAGE_INSTRUCTIONS,
} from './tools/index.js';
const agent = createAgent({
model: 'gpt-5.5',
tools: [
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
],
systemPrompt: TRIAGE_INSTRUCTIONS,
});
const result = await agent.invoke({
messages: [
{
role: 'user',
content:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
},
],
recursionLimit: 15,
});
const answer = [...result.messages]
.reverse()
.find((message) => message.type === 'ai');
console.log(answer?.content);

Inspect result.messages for the full trace: human input, AI tool-call messages, tool results, and the final AI reply.

Streaming

agent.stream() yields state updates as the graph runs. Use streamMode: 'values' to receive the full message list after each step:

const stream = await agent.stream(
{
messages: [
{
role: 'user',
content:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
},
],
},
{ streamMode: 'values', recursionLimit: 15 },
);
let finalMessages = [];
for await (const state of stream) {
if (state.messages) {
finalMessages = state.messages;
}
}
const answer = [...finalMessages]
.reverse()
.find((message) => message.type === 'ai');
console.log(answer?.content);

For token-level streaming, use streamMode: 'messages' or streamEvents (see LangGraph streaming).

When to pick LangChain

LangChain createAgentVercel AI SDKOpenAI Agents SDK
Best forRAG + LCEL + agents in one stackTypeScript apps already on AI SDKOpenAI-first agent primitives
Tool definitiontool() + Zod schematool() + inputSchematool() + Zod parameters
Run APIagent.invoke / agent.streamgenerateText + stopWhenrun() + maxTurns
Handoffs / guardrailsMiddleware (advanced)LimitedBuilt-in
MemoryLangGraph checkpointersBring your ownSession helpers

Pick LangChain when document loaders, retrievers, and agents should share one ecosystem. Pick Vercel AI SDK or OpenAI Agents SDK when you want a focused agent layer without the broader LangChain surface.

Demo

See the langchain-agents-nodejs-demo folder for runnable scripts: single-tool lookup, full triage, and streaming.

Building AI agents with OpenAI Agents SDK

June 12, 2026

The OpenAI Agents SDK (@openai/agents) is OpenAI's official framework for agentic apps in TypeScript. It provides a small set of primitives: Agent, tools, handoffs, guardrails, and a run loop managed by run().

This post builds the same support triage agent as the Building AI agents with Vercel AI SDK post - lookup customers and invoices, search a knowledge base, then create a ticket or escalate - but uses the OpenAI SDK instead of the Vercel tool loop.

For lower-level API access, see the OpenAI Responses API post. For the Vercel AI SDK alternative (generateText, stopWhen, stepCountIs), see the Vercel AI SDK agents post. For the same scenario with LangChain (createAgent, tool(), agent.invoke), see Building AI agents with LangChain.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • @openai/agents and zod installed (npm i @openai/agents zod)
  • OPENAI_API_KEY set in the environment

Mental model - turns and the agent loop

A turn is one model generation. In that turn the model either:

  • returns final output (the run ends), or
  • returns tool calls (the SDK executes them and starts another turn with the results), or
  • requests a handoff to another agent (control switches, history is preserved, loop continues)

Typical flow for the support triage agent: user question → model calls lookup tools (get_customer, get_invoice, search_knowledge_base) → model creates a ticket or escalates → final answer.

maxTurns: 8 means “stop after 8 turns” (eight model generations), not eight individual tool calls. A single turn can include multiple parallel tool calls.

When you omit maxTurns, the SDK defaults to 10 as a safety cap.

Support triage scenario

Example prompt:

Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?

A realistic chain:

  1. get_customer - plan tier, open ticket count
  2. get_invoice - amount, status, payment IDs
  3. search_knowledge_base - duplicate-charge and refund policy
  4. create_support_ticket or escalate_to_human - write action or escalation

The demo uses in-memory fixtures (customers, invoices, knowledge-base articles) so scripts run without a database.

Defining multiple tools

Register tools with tool() and Zod parameters. Clear description values help the model pick the right tool.

import { tool } from '@openai/agents';
import { z } from 'zod';
const getCustomer = tool({
name: 'get_customer',
description: 'Look up a customer account by ID',
parameters: z.object({
customerId: z.string().describe('Customer ID, e.g. cus_1042'),
}),
execute: async ({ customerId }) => {
const customer = customers.find((item) => item.id === customerId);
if (!customer) {
return { found: false, customerId, error: 'Customer not found' };
}
return { found: true, customer };
},
});
const getInvoice = tool({
name: 'get_invoice',
description: 'Look up an invoice by ID, including payment IDs and status',
parameters: z.object({
invoiceId: z.string().describe('Invoice ID, e.g. inv_8891'),
}),
execute: async ({ invoiceId }) => {
const invoice = invoices.find((item) => item.id === invoiceId);
if (!invoice) {
return { found: false, invoiceId, error: 'Invoice not found' };
}
return { found: true, invoice };
},
});
const searchKnowledgeBase = tool({
name: 'search_knowledge_base',
description: 'Search internal support articles by keyword',
parameters: z.object({
query: z.string().describe('Search terms, e.g. duplicate charge refund'),
}),
execute: async ({ query }) => {
// keyword match against mocked articles
return { query, articles: matches };
},
});

Add write tools for outcomes:

const createSupportTicket = tool({
name: 'create_support_ticket',
description: 'Create a support ticket after gathering customer and policy context',
parameters: z.object({
customerId: z.string(),
subject: z.string().min(3),
priority: z.enum(['low', 'medium', 'high']),
summary: z.string().min(10),
}),
execute: async (input) => {
const ticket = createTicket(input);
return { created: true, ticket };
},
});
const escalateToHuman = tool({
name: 'escalate_to_human',
description: 'Escalate when policy requires manual review',
parameters: z.object({
customerId: z.string(),
reason: z.string().min(10),
urgency: z.enum(['normal', 'high']),
}),
execute: async (input) => ({
escalated: true,
queue: input.urgency === 'high' ? 'billing-urgent' : 'billing-standard',
...input,
}),
});

Return structured objects from execute. The SDK serializes them as tool results for the next turn. Return explicit errors (for example { found: false, error: '...' }) so the model can recover instead of throwing.

Running an agent

Define an Agent with instructions and tools, then call run():

import { Agent, run } from '@openai/agents';
const agent = new Agent({
name: 'Support Triage',
model: 'gpt-5.5',
instructions: `You are a billing support triage agent.
- Look up customer and invoice before recommending refunds.
- Search the knowledge base for policy guidance.
- Create a ticket when you can resolve within policy.
- Call escalate_to_human when manual review is required.`,
tools: [
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
],
});
const result = await run(
agent,
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
{ maxTurns: 8 },
);
console.log(result.finalOutput);

Use a model that supports tool calling.

maxTurns - cap the number of turns

maxTurns(n) stops once the run reaches n turns. Use it on every production agent to prevent runaway loops and unbounded API cost. When the cap is exceeded, the SDK throws MaxTurnsExceededError.

Use caseSuggested cap
Single tool, then answer2
Chat with occasional tool use3–5
Task agents (triage, research)8–15
Long autonomous workflows15–20 (with monitoring)

Tight vs relaxed cap on the same prompt:

import { Agent, run } from '@openai/agents';
// Stops after 3 turns even if the model still wants more context
const tight = await run(agent, prompt, { maxTurns: 3 });
// Allows a fuller investigation chain
const relaxed = await run(agent, prompt, { maxTurns: 8 });

Inspecting runs

The newItems array on the result contains tool calls, tool outputs, and messages from the run. Use it for debugging:

const result = await run(agent, prompt, { maxTurns: 8 });
for (const item of result.newItems) {
if (item.type === 'tool_call_item') {
console.log('tool:', item.rawItem.name, item.rawItem.arguments);
}
if (item.type === 'tool_call_output_item') {
console.log('output:', item.output);
}
}
console.log('lastAgent:', result.lastAgent.name);
console.log('answer:', result.finalOutput);

The SDK emits traces automatically. Set workflowName on a custom Runner to group related runs in the OpenAI Traces dashboard.

Handoffs

For multi-agent workflows, define specialist agents and wire them with Agent.create() and handoffs. The triage agent in this post stays single-agent, but handoffs are the SDK's way to delegate between agents (similar to routing a case to a billing specialist):

import { Agent } from '@openai/agents';
const billingAgent = new Agent({
name: 'Billing Specialist',
instructions: 'Handle refund and duplicate-charge cases.',
tools: [getInvoice, searchKnowledgeBase, createSupportTicket],
});
const triageAgent = Agent.create({
name: 'Triage',
instructions: 'Route billing cases to the billing specialist when needed.',
handoffs: [billingAgent],
});

After a run, check result.lastAgent to see which agent produced the final output.

Streaming

Pass stream: true to receive events as the run progresses:

import { Agent, run } from '@openai/agents';
const stream = await run(agent, prompt, { maxTurns: 8, stream: true });
process.stdout.write('Answer: ');
for await (const event of stream) {
if (event.type === 'raw_model_stream_event' && event.data.type === 'output_text_delta') {
process.stdout.write(event.data.delta);
}
if (event.type === 'run_item_stream_event' && event.name === 'tool_called') {
console.error(`\nTool: ${event.item.rawItem.name}`);
}
}
await stream.completed;
console.log('\nDone:', stream.finalOutput);

Text streams incrementally. Tool calls appear as run_item_stream_event events between text segments.

Production notes

  • Always set maxTurns - do not rely on the default cap without monitoring
  • Cost - each turn is another model call; inspect newItems or stream events for tool usage
  • Tool errors - return structured errors from execute instead of throwing when the model should retry or escalate
  • Instructions - keep policy rules in instructions, not only in the user prompt
  • Tracing - use the OpenAI Traces dashboard to debug multi-turn runs
  • Alternatives - hosted tools (web search, code interpreter), MCP servers, and sandbox agents are covered in the official docs

Demo

Runnable scripts for each section live in the openai-agents-sdk-demo folder. Get access via code demos.

Building AI agents with Vercel AI SDK

June 9, 2026

The Vercel AI SDK treats agents as tool-calling loops: the model generates text or invokes tools, the SDK runs those tools, and the loop continues until the model answers or a stop condition fires.

This post builds a support triage agent that looks up customers and invoices, searches an internal knowledge base, and either opens a ticket or escalates to a human. It builds on the LLM integration with Vercel AI SDK post and focuses on multiple tools, stopWhen, and stepCountIs.

For external tools exposed over MCP instead of SDK-native tool() handlers, see the MCP server with Node.js post. For the same triage scenario with the official OpenAI Agents SDK (@openai/agents, run(), maxTurns), see the dedicated post. For the LangChain stack (createAgent, tool(), LangGraph loop), see Building AI agents with LangChain.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • ai, @ai-sdk/openai, and zod installed (npm i ai @ai-sdk/openai zod)
  • Client setup from the Vercel AI SDK integration post

Mental model - steps and the tool loop

A step is one model generation. In that step the model either:

  • returns text (the loop ends), or
  • returns tool calls (the SDK executes them and starts another step with the results)

Typical flow for the support triage agent: user question → model calls lookup tools (getCustomer, getInvoice, searchKnowledgeBase) → model creates a ticket or escalates → final answer. stopWhen can end the loop before or after the write tools run.

stepCountIs(5) means "stop after 5 steps" (five model generations), not five individual tool calls. A single step can include multiple parallel tool calls.

When you pass tools without stopWhen, the SDK defaults to stepCountIs(20) as a safety cap.

Support triage scenario

Example prompt:

Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?

A realistic chain:

  1. getCustomer - plan tier, open ticket count
  2. getInvoice - amount, status, payment IDs
  3. searchKnowledgeBase - duplicate-charge and refund policy
  4. createSupportTicket or escalateToHuman - write action or sentinel stop

The demo uses in-memory fixtures (customers, invoices, knowledge-base articles) so scripts run without a database.

Defining multiple tools

Register tools with tool() and Zod inputSchema. Clear description values help the model pick the right tool.

import { tool } from 'ai';
import { z } from 'zod';
const getCustomer = tool({
description: 'Look up a customer account by ID',
inputSchema: z.object({
customerId: z.string().describe('Customer ID, e.g. cus_1042'),
}),
execute: async ({ customerId }) => {
const customer = customers.find((item) => item.id === customerId);
if (!customer) {
return { found: false, customerId, error: 'Customer not found' };
}
return { found: true, customer };
},
});
const getInvoice = tool({
description: 'Look up an invoice by ID, including payment IDs and status',
inputSchema: z.object({
invoiceId: z.string().describe('Invoice ID, e.g. inv_8891'),
}),
execute: async ({ invoiceId }) => {
const invoice = invoices.find((item) => item.id === invoiceId);
if (!invoice) {
return { found: false, invoiceId, error: 'Invoice not found' };
}
return { found: true, invoice };
},
});
const searchKnowledgeBase = tool({
description: 'Search internal support articles by keyword',
inputSchema: z.object({
query: z.string().describe('Search terms, e.g. duplicate charge refund'),
}),
execute: async ({ query }) => {
// keyword match against mocked articles
return { query, articles: matches };
},
});

Add write tools for outcomes:

const createSupportTicket = tool({
description: 'Create a support ticket after gathering customer and policy context',
inputSchema: z.object({
customerId: z.string(),
subject: z.string().min(3),
priority: z.enum(['low', 'medium', 'high']),
summary: z.string().min(10),
}),
execute: async (input) => {
const ticket = createTicket(input);
return { created: true, ticket };
},
});
const escalateToHuman = tool({
description: 'Escalate when policy requires manual review',
inputSchema: z.object({
customerId: z.string(),
reason: z.string().min(10),
urgency: z.enum(['normal', 'high']),
}),
execute: async (input) => ({
escalated: true,
queue: input.urgency === 'high' ? 'billing-urgent' : 'billing-standard',
...input,
}),
});

Return structured objects from execute. The SDK serializes them as tool results for the next step. Return explicit errors (for example { found: false, error: '...' }) so the model can recover instead of throwing.

Multi-step triage with generateText

Pass all tools and a system prompt with triage rules:

import { generateText, stepCountIs } from 'ai';
const { text, steps } = await generateText({
model: openai('gpt-5.5'),
system: `You are a billing support triage agent.
- Look up customer and invoice before recommending refunds.
- Search the knowledge base for policy guidance.
- Create a ticket when you can resolve within policy.
- Call escalateToHuman when manual review is required.`,
tools: {
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
},
stopWhen: stepCountIs(8),
prompt:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
});
console.log('steps:', steps.length);
console.log(text);

Use a model that supports tool calling (same requirement as web search in the Vercel AI SDK post).

stopWhen - when the loop stops

stopWhen defines stopping conditions for the tool loop. Conditions are evaluated only when the last step contains tool results.

  • A single condition stops when that condition returns true
  • An array stops when any condition returns true (OR logic)
  • Without stopWhen, the SDK applies stepCountIs(20)

The loop also ends naturally when the model returns text without further tool calls.

stepCountIs - cap the number of steps

stepCountIs(n) stops once steps.length reaches n. Use it on every production agent to prevent runaway loops and unbounded API cost.

Use caseSuggested cap
Single tool, then answer2 (tool step + text step)
Chat with occasional tool use3-5
Task agents (triage, research)8-15
Long autonomous workflows15-20 (with monitoring)

Tight vs relaxed cap on the same prompt:

import { generateText, stepCountIs } from 'ai';
// Stops after 3 steps even if the model still wants more context
const capped = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(3),
prompt: '...',
});
// Allows a fuller investigation chain
const relaxed = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: '...',
});

Combining hasToolCall with stepCountIs

hasToolCall('toolName') stops when the model invokes a specific tool in the latest step. Pair it with stepCountIs for a hard cap plus a sentinel tool:

import { generateText, stepCountIs, hasToolCall } from 'ai';
const { text, steps } = await generateText({
model: openai('gpt-5.5'),
system: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: [stepCountIs(10), hasToolCall('escalateToHuman')],
prompt:
'Customer cus_2201 on the starter plan reports a duplicate $190 charge on invoice inv_9104.',
});

escalateToHuman works well as a sentinel: the loop stops as soon as the model decides the case needs a human, without waiting for a final text-only step.

Inspecting steps and usage

The steps array on the result contains per-step tool calls, tool results, finish reason, and usage. Use it for debugging and cost tracking:

const { text, steps, totalUsage } = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: '...',
});
for (const [index, step] of steps.entries()) {
console.log(`step ${index + 1}`);
console.log(' toolCalls:', step.toolCalls?.map((c) => c.toolName));
console.log(' usage:', step.usage);
}
console.log('totalUsage:', totalUsage);

With streamText, pass onStepFinish to log each step as it completes.

ToolLoopAgent - reusable agent definition

ToolLoopAgent wraps the same loop for reuse across scripts and API routes. It accepts the same settings as generateText (tools, stopWhen, instructions).

import { ToolLoopAgent, stepCountIs } from 'ai';
const supportTriageAgent = new ToolLoopAgent({
model: openai('gpt-5.5'),
instructions: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: stepCountIs(8),
});
const result = await supportTriageAgent.generate({
prompt:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
onStepFinish: async ({ stepNumber, usage, toolCalls }) => {
console.log(`step ${stepNumber + 1}`, {
tokens: usage.totalTokens,
tools: toolCalls?.map((call) => call.toolName),
});
},
});
console.log(result.text);

Use .stream() for streaming. For Next.js chat UIs, see createAgentUIStreamResponse in the AI SDK agents docs.

Streaming with tools

streamText supports the same tools and stopWhen settings:

import { streamText, stepCountIs } from 'ai';
const result = streamText({
model: openai('gpt-5.5'),
system: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: 'Customer cus_1042 says they were charged twice for invoice inv_8891.',
onStepFinish: async ({ stepNumber, toolCalls }) => {
console.error(`step ${stepNumber + 1}:`, toolCalls?.map((c) => c.toolName));
},
});
for await (const part of result.textStream) {
process.stdout.write(part);
}

Text streams incrementally. Tool calls run between text segments as the loop progresses.

Production notes

  • Always set stopWhen - do not rely on the default stepCountIs(20) in production without monitoring
  • Cost - each step is another model call; log steps or onStepFinish usage
  • Tool errors - return structured errors from execute instead of throwing when the model should retry or escalate
  • Instructions - keep policy rules in system / instructions, not only in the user prompt
  • Same patterns elsewhere - PR review (listPRsgetCheckssubmitReview) or job-fit scoring use the same loop mechanics with different tools

Demo

Runnable scripts for each section live in the vercel-ai-sdk-agents-demo folder. Get access via code demos.