
In our previous guide on adding tool calling to our Next.js streaming agent, we connected our three Zod-validated server tools to streamText and used maxSteps: 5 so the model could query documentation and inspect open issues before answering. That built-in SDK loop handles simple one-or-two-tool lookups nicely, but it starts showing cracks as soon as a user submits a complex, multi-part engineering problem where Step 2 depends on parsing specific IDs from Step 1, filtering out noisy observations, and following a strict sequence of reasoning checkpoints.
To build a deterministic multi-step AI agent in Next.js that can plan, chain dependent tool calls, and inspect intermediate observations without losing track of its goal, you use LangChain.js (@langchain/core and langchain) with DynamicStructuredTool, a ChatPromptTemplate containing an agent_scratchpad placeholder, and createToolCallingAgent wrapped inside AgentExecutor. This architecture gives your backend explicit control over the intermediate reasoning scratchpad, custom tool-error recovery, and step-by-step trace inspection across both Claude 3.5 Sonnet and GPT-4o.
When a junior developer asks our DevPulse agent a compound question like, 'Our auth service is throwing 429 errors during token refresh—check the rate-limit docs for the burst policy, verify if an open bug already exists for that component, and if not, prepare a high-severity ticket draft,' the agent cannot execute all three tools blindly at once. It must reason sequentially: read the docs first, extract the exact component and header rules, search the issue tracker using those extracted terms, evaluate whether the returned issue matches the user's symptoms, and only then decide whether to call draftIssueTicket.
When streamText(maxSteps) Is Enough vs. When You Need LangChain.js
A question every full-stack TypeScript engineer asks is: 'If the Vercel AI SDK already supports tools and maxSteps, why bring LangChain.js into my Next.js project at all?'
The answer comes down to how much control you need over the Agent Scratchpad—the internal memory buffer of Thought -> Action -> Observation turns that accumulates between the initial user prompt and the final answer:
- Vercel AI SDK (
streamTextwithmaxSteps): Best when the LLM just needs a fast, lightweight loop to call one or two independent tools and stream tokens straight touseChat. You do not mutate or prune the intermediate tool outputs between steps. - LangChain.js (
createToolCallingAgent+AgentExecutor/ LangGraph): Essential when you need explicit control overintermediateSteps, custom prompt instructions injected directly into theagent_scratchpad, automatic retry formatting when a model outputs malformed tool JSON, or sequential state handoffs where each step's observation is transformed before the next LLM call.
Instead of treating these two libraries as enemies, experienced Next.js developers reuse the exact same blueprint.ts Zod schemas and tools.ts business logic across both.

How Multi-Step Reasoning Works Inside LangChain.js
In a single-step tool call, the model guesses which function to run based solely on the user's raw question. In a true multi-step reasoning loop, the model operates as an iterative state machine driven by the agent_scratchpad.
Here is how a three-step diagnostic query moves through the LangChain.js orchestrator inside our Next.js backend:
`mermaid flowchart TD Query([User Compound Query]) --> Prompt[ChatPromptTemplate + System Rules] Prompt --> Scratchpad[agent_scratchpad: Previous Step Actions & Observations] Scratchpad --> Brain{LangChain Tool-Calling Agent Claude 3.5 Sonnet / GPT-4o}
Brain -->|Step 1: Missing Policy Context| Tool1[Tool: searchKnowledgeBase
Returns Rate-Limit Doc Excerpt] Tool1 --> Obs1[Observation 1 Appended to Scratchpad] Obs1 --> Scratchpad
Brain -->|Step 2: Check Existing Bug by Component| Tool2[Tool: checkIssueTracker
Returns Open Issue ISS-402] Tool2 --> Obs2[Observation 2 Appended to Scratchpad] Obs2 --> Scratchpad
Brain -->|Step 3: Evaluate Match & Synthesize| Final[Return Final Answer + intermediateSteps Trace]
`
Architecture Flow Summary: The user's compound query is combined with system instructions and an initially empty agent_scratchpad inside a ChatPromptTemplate. On Step 1, the LangChain agent calls searchKnowledgeBase and appends Observation 1 to the scratchpad; on Step 2, it uses context from Observation 1 to query checkIssueTracker and appends Observation 2; on Step 3, seeing that ISS-402 already covers the bug, it skips drafting a duplicate ticket and returns the synthesized answer alongside the full intermediateSteps trace.
Step 1: Installing the Modular LangChain.js Packages
Modern LangChain.js (v0.3+) is split into lightweight, scoped packages so you never bloat your Next.js serverless bundle with unused integrations. Install the core runtime along with the OpenAI and Anthropic partner packages:
bash npm install langchain @langchain/core @langchain/openai @langchain/anthropic zod server-only
Because we already configured OPENAI_API_KEY and ANTHROPIC_API_KEY in .env.local back in Post 14, no new environment variables are required.
Step 2: Adapting Our Zod Blueprint into LangChain Structured Tools
Let us create src/lib/agent/langchain-tools.ts. Notice how we import our existing searchDocsSchema, checkIssuesSchema, and draftTicketSchema from blueprint.ts and executeAgentTool from tools.ts. We wrap them with the tool helper from @langchain/core/tools.
One critical difference between the Vercel AI SDK and LangChain.js is that LangChain tool executors must return a serialized string (or content array) to be inserted into the agent_scratchpad message history.
`typescript // src/lib/agent/langchain-tools.ts import 'server-only'; import { tool } from '@langchain/core/tools'; import { searchDocsSchema, checkIssuesSchema, draftTicketSchema, } from './blueprint'; import { executeAgentTool } from './tools';
export const searchDocsLangChainTool = tool( async (input) => { const result = await executeAgentTool('searchKnowledgeBase', input); return JSON.stringify(result); }, { name: 'searchKnowledgeBase', description: 'Step 1 priority tool: Search internal technical documentation for rate limits, auth rules, and architecture specifications.', schema: searchDocsSchema, } );
export const checkIssuesLangChainTool = tool( async (input) => { const result = await executeAgentTool('checkIssueTracker', input); return JSON.stringify(result); }, { name: 'checkIssueTracker', description: 'Step 2 verification tool: Query the repository issue tracker using exact keywords or component names discovered in documentation or user logs.', schema: checkIssuesSchema, } );
export const draftTicketLangChainTool = tool( async (input) => { const result = await executeAgentTool('draftIssueTicket', input); return JSON.stringify(result); }, { name: 'draftIssueTicket', description: 'Step 3 conditional action tool: Draft a new bug ticket ONLY AFTER verifying via checkIssueTracker that no open issue already exists for the bug.', schema: draftTicketSchema, } );
export const devPulseLangChainTools = [ searchDocsLangChainTool, checkIssuesLangChainTool, draftTicketLangChainTool, ]; `
Look at how the description strings in langchain-tools.ts explicitly encode step ordering ('Step 1 priority tool', 'Step 2 verification tool', 'Step 3 conditional action tool — ONLY AFTER verifying via checkIssueTracker'). Giving the model explicit dependency cues inside tool descriptions prevents it from jumping straight to Step 3 (draftIssueTicket) before checking whether the bug is already logged.
Step 3: Building the Multi-Step Orchestrator (multi-step-agent.ts)
Now let us create src/lib/agent/multi-step-agent.ts. This module constructs a dual-provider model instance (ChatAnthropic or ChatOpenAI), defines a structured multi-step ChatPromptTemplate with MessagesPlaceholder('agent_scratchpad'), and runs AgentExecutor with returnIntermediateSteps: true and maxIterations: 5.
`typescript // src/lib/agent/multi-step-agent.ts import 'server-only'; import { ChatOpenAI } from '@langchain/openai'; import { ChatAnthropic } from '@langchain/anthropic'; import { ChatPromptTemplate, MessagesPlaceholder, } from '@langchain/core/prompts'; import { AIMessage, HumanMessage, type BaseMessage, } from '@langchain/core/messages'; import { createToolCallingAgent, AgentExecutor } from 'langchain/agents'; import { type AgentConfig, type AgentStepTrace, type ToolName, } from './blueprint'; import { sanitizeConversationHistory, type ChatMessage, } from './llm-client'; import { devPulseLangChainTools } from './langchain-tools';
function createLangChainModel(config: AgentConfig) { if (config.modelId === 'gpt-4o') { return new ChatOpenAI({ model: 'gpt-4o', temperature: config.temperature, apiKey: process.env.OPENAI_API_KEY, }); }
return new ChatAnthropic({ model: 'claude-3-5-sonnet-latest', temperature: config.temperature, apiKey: process.env.ANTHROPIC_API_KEY, }); }
function convertToLangChainMessages(messages: ChatMessage[]): BaseMessage[] { const sanitized = sanitizeConversationHistory(messages, 10); return sanitized.map((m) => m.role === 'user' ? new HumanMessage(m.content) : new AIMessage(m.content) ); }
export interface MultiStepExecutionResult { finalAnswer: string; steps: AgentStepTrace[]; totalDurationMs: number; }
export async function runMultiStepDevPulseAgent( history: ChatMessage[], userInput: string, config: AgentConfig ): Promise { const startTime = Date.now(); const llm = createLangChainModel(config);
const prompt = ChatPromptTemplate.fromMessages([ [ 'system', [ config.systemPrompt, 'You are operating in Multi-Step Reasoning Mode.', 'Follow this strict workflow when diagnosing issues:', '1. First, search documentation (searchKnowledgeBase) to verify expected system behavior.', '2. Second, check the issue tracker (checkIssueTracker) using the exact error code or component.', '3. Third, only call draftIssueTicket if the user asks to log a bug AND no matching open issue exists.', '4. Synthesize findings clearly, citing both the doc path and issue ID.', ].join('\n'), ], new MessagesPlaceholder('chat_history'), ['human', '{input}'], new MessagesPlaceholder('agent_scratchpad'), ]);
const agent = createToolCallingAgent({ llm, tools: devPulseLangChainTools, prompt, });
const executor = new AgentExecutor({ agent, tools: devPulseLangChainTools, maxIterations: config.maxSteps, returnIntermediateSteps: true, handleParsingErrors: 'Tool argument validation failed. Check the required Zod schema fields and retry with valid parameters.', });
const chatHistory = convertToLangChainMessages(history); const response = await executor.invoke({ input: userInput, chat_history: chatHistory, });
const rawSteps = Array.isArray(response.intermediateSteps) ? response.intermediateSteps : [];
const steps: AgentStepTrace[] = rawSteps.map((step, idx) => { const toolName = step.action?.tool as ToolName | undefined; const toolInput = step.action?.toolInput; let parsedObservation: unknown = step.observation;
if (typeof step.observation === 'string') {
try {
parsedObservation = JSON.parse(step.observation);
} catch {
parsedObservation = step.observation;
}
}
const rawLog =
typeof step.action?.log === 'string' && step.action.log.trim().length > 0
? step.action.log.trim()
: `Executed ${toolName ?? 'tool'} in multi-step chain.`;
return {
stepIndex: idx + 1,
thoughtSummary: rawLog.slice(0, 220),
selectedTool: toolName,
toolInput,
toolOutput: parsedObservation,
durationMs: Math.round((Date.now() - startTime) / Math.max(rawSteps.length, 1)),
};
});
return { finalAnswer: typeof response.output === 'string' ? response.output : JSON.stringify(response.output), steps, totalDurationMs: Date.now() - startTime, }; } `
Examine three critical engineering details in runMultiStepDevPulseAgent:
new MessagesPlaceholder('agent_scratchpad'): This is the secret sauce of LangChain's multi-step loop. Every time the agent calls a tool,AgentExecutorformats theAIMessagetool call and the resultingToolMessageJSON string and injects them intoagent_scratchpadbefore invoking the LLM for the next iteration.handleParsingErrorsString: If the LLM hallucinates a number out of range (for example, passingtopK: 20when our Zod schema capstopKat5), instead of crashing the entire Next.js request,AgentExecutorcatches the Zod error, feeds our instructional error string back intoagent_scratchpad, and lets the model self-correct on the next step!- Mapping
intermediateStepstoAgentStepTrace: By settingreturnIntermediateSteps: true, we extractstep.action.tool,step.action.toolInput,step.action.log(the model's intermediate reasoning text), andstep.observationdirectly into the exactAgentStepTracearray our frontend already knows how to render.
Step 4: Wiring the Server Action and Multi-Step Reasoning UI
Let us expose runMultiStepDevPulseAgent through a Next.js Server Action in src/app/actions/multi-step.ts and build a diagnostic UI component that visualizes the chained reasoning steps.
`typescript // src/app/actions/multi-step.ts 'use server';
import { z } from 'zod'; import { defaultDevPulseConfig, type AgentStepTrace, } from '@/lib/agent/blueprint'; import { runMultiStepDevPulseAgent } from '@/lib/agent/multi-step-agent';
const multiStepInputSchema = z.object({ prompt: z.string().trim().min(5).max(3000), modelId: z .enum(['gpt-4o', 'claude-3-5-sonnet-latest']) .default('claude-3-5-sonnet-latest'), });
export interface MultiStepActionResponse { success: boolean; finalAnswer: string; steps: AgentStepTrace[]; totalDurationMs: number; errorMessage?: string; }
export async function executeMultiStepTriage( rawInput: unknown ): Promise { const parsed = multiStepInputSchema.safeParse(rawInput); if (!parsed.success) { return { success: false, finalAnswer: '', steps: [], totalDurationMs: 0, errorMessage: parsed.error.issues.map((i) => i.message).join('; '), }; }
try { const result = await runMultiStepDevPulseAgent( [], parsed.data.prompt, { ...defaultDevPulseConfig, modelId: parsed.data.modelId, } );
return {
success: true,
finalAnswer: result.finalAnswer,
steps: result.steps,
totalDurationMs: result.totalDurationMs,
};
} catch (err) { const message = err instanceof Error ? err.message : 'Multi-step execution failed'; return { success: false, finalAnswer: '', steps: [], totalDurationMs: 0, errorMessage: message, }; } } `
Now create the client component src/components/MultiStepTriageConsole.tsx to test a 3-part compound prompt and inspect the chain of steps:
`tsx // src/components/MultiStepTriageConsole.tsx 'use client';
import { useState, useTransition } from 'react'; import { executeMultiStepTriage, type MultiStepActionResponse, } from '@/app/actions/multi-step';
export default function MultiStepTriageConsole() { const [prompt, setPrompt] = useState( 'Our client gets HTTP 429 during token refresh. Check the rate-limit docs first, then verify if an open issue exists for 429 bursts, and only draft a ticket if it is undocumented.' ); const [result, setResult] = useState<MultiStepActionResponse | null>(null); const [isPending, startTransition] = useTransition();
function handleRun(e: React.FormEvent) { e.preventDefault(); if (!prompt.trim() || isPending) return;
startTransition(async () => {
const res = await executeMultiStepTriage({
prompt,
modelId: 'claude-3-5-sonnet-latest',
});
setResult(res);
});
}
return ( DevPulse Multi-Step Reasoning Engine (LangChain.js) Sequential Scratchpad Chaining | Self-Correcting Zod Parser
<form onSubmit={handleRun} className='space-y-3'>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={3}
className='w-full p-3 rounded-lg bg-slate-950 border border-slate-700 text-sm'
/>
<button
type='submit'
disabled={isPending}
className='px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-sm font-semibold'
>
{isPending ? 'Executing Multi-Step Chain...' : 'Run Multi-Step Agent'}
</button>
</form>
{result && result.success && (
<div className='space-y-4 pt-3 border-t border-slate-800'>
<div className='space-y-2'>
<div className='text-xs uppercase tracking-wider text-slate-400 font-semibold'>
Chained Reasoning Steps ({result.steps.length} iterations • {result.totalDurationMs}ms)
</div>
{result.steps.map((step) => (
<div
key={step.stepIndex}
className='p-3 rounded-lg bg-slate-950 border border-purple-900/60 text-xs space-y-1.5'
>
<div className='flex justify-between font-mono text-purple-400'>
<span>
Step {step.stepIndex} → {step.selectedTool}
</span>
<span>Input: {JSON.stringify(step.toolInput)}</span>
</div>
<div className='text-slate-300'>{step.thoughtSummary}</div>
<div className='text-[11px] font-mono text-emerald-400 bg-slate-900/90 p-2 rounded truncate'>
Observation: {JSON.stringify(step.toolOutput)}
</div>
</div>
))}
</div>
<div className='p-4 rounded-lg bg-slate-950 border border-cyan-800/60 text-sm whitespace-pre-wrap leading-relaxed'>
{result.finalAnswer}
</div>
</div>
)}
</div>
); } `

A Real Debugging Lesson: Why My LangChain Agent Called the Same Tool 5 Times in a Row
When I first wired up a multi-step LangChain.js AgentExecutor in a Next.js backend to chain a documentation lookup with an issue tracker check, my agent kept hitting maxIterations: 5 by calling searchKnowledgeBase five times in a row with the exact same query before giving up.
At first, I blamed the system prompt. I kept adding lines like 'DO NOT CALL THE SAME TOOL TWICE' in all caps, yet Claude and GPT-4o still repeated the call. When I finally logged the raw agent_scratchpad messages inside AgentExecutor, I immediately spotted my mistake: inside my tool(async (input) => { ... }) wrapper, I had returned a raw JavaScript object (return result;) instead of a serialized JSON string (return JSON.stringify(result);), and in one branch where a helper function had no return statement, it resolved to undefined.
When LangChain.js builds the ToolMessage that goes into agent_scratchpad, if your tool function returns undefined or an unserialized object that gets coerced to '[object Object]', the LLM looks at its scratchpad on Step 2, sees an empty or garbled observation, assumes the tool failed silently, and tries calling it again! Wrapping every LangChain tool return value in JSON.stringify(result) and placing the new MessagesPlaceholder('agent_scratchpad') at the very bottom of ChatPromptTemplate.fromMessages immediately fixed the loop. On the next run, the agent read the rate-limit doc on Step 1, saw the JSON excerpt in its scratchpad, queried checkIssueTracker for '429' on Step 2, noticed ISS-402 was already open, and skipped draftIssueTicket automatically.
Common Mistakes When Building Multi-Step Agents with LangChain.js
Before we move to building custom external tools in Post 19, watch out for these four multi-step pitfalls:
1. Forgetting MessagesPlaceholder('agent_scratchpad') in the Prompt Template
If you omit new MessagesPlaceholder('agent_scratchpad')—or accidentally misspell the key name—createToolCallingAgent has nowhere to inject the ToolMessage observations from Step 1. As a result, on Step 2 the LLM has complete amnesia about what it just executed on Step 1 and will call Step 1's tool endlessly.
2. Using the Legacy initializeAgentExecutorWithOptions Function
Many older blog posts and tutorials still teach initializeAgentExecutorWithOptions, which was deprecated in modern LangChain.js and relies on brittle regex parsing of plain text (Action: ... Action Input: ...). Always use createToolCallingAgent (or LangGraph's createReactAgent), which uses native OpenAI and Anthropic tool-calling JSON schemas.
3. Leaving handleParsingErrors Disabled
In a 4-step chain, there is always a chance the model slightly misformats a Zod enum value on Step 2. If handleParsingErrors is false, that single schema mismatch throws an exception and destroys the work already completed in Step 1. Passing a clear instructional message to handleParsingErrors lets AgentExecutor feed the Zod validation error back to the model so it fixes the parameter on the next step.
4. Importing Legacy Monolithic langchain Paths in Next.js Client Components
Never import @langchain/core or langchain/agents inside a 'use client' file. LangChain packages rely on Node.js runtime primitives and environment variables. Always isolate LangChain orchestration inside files guarded by import 'server-only' and expose them via Next.js Server Actions or Route Handlers.
Frequently Asked Questions
Should I use createToolCallingAgent with AgentExecutor or LangGraph.js for multi-step agents?
For sequential multi-step tool calling where a single agent plans, calls tools, and inspects agent_scratchpad observations up to maxIterations: 5, createToolCallingAgent with AgentExecutor is fast to set up and easy to debug. When your system grows into cyclic graphs with branching human-in-the-loop checkpoints or multiple specialized sub-agents handing state to one another (which we will design later in this series), upgrading to @langchain/langgraph is the natural next step.
Can I use the exact same Zod schemas for both the Vercel AI SDK and LangChain.js?
Yes. As we demonstrated in src/lib/agent/langchain-tools.ts, both the Vercel AI SDK's tool({ parameters }) and LangChain's tool(fn, { schema }) accept standard Zod object schemas directly. Keeping your Zod contracts in a framework-agnostic blueprint.ts file lets you switch or combine orchestration libraries without rewriting tool definitions.
How do I prevent a multi-step LangChain agent from burning too many tokens in agent_scratchpad?
Every tool observation appended to agent_scratchpad is re-sent to the LLM on every subsequent step of that turn. Keep your tool JSON outputs compact (under 300–500 tokens per tool call), enforce maxIterations: 5 on AgentExecutor, and enable handleParsingErrors so the agent finishes its chain in 2 to 3 steps rather than spinning in retry loops.
Need a High-Performance Web App or Custom AI Solution?
I help founders, businesses, and engineering teams build lightning-fast web applications, resilient backend architectures, and intelligent AI workflows. Have an idea in mind? Let’s bring it to life.
Found this article helpful?
Give some claps to support more in-depth engineering logs!
Zahid Hasan Tonmoy
Author & Developer
MERN Full Stack Developer & AI Agent Developer based in Dhaka, Bangladesh. Writing about web development, React, PostgreSQL and my learning journey.