LangGraph Tutorial (TypeScript): debugging agent loops for beginners
This tutorial shows you how to catch, inspect, and stop agent loops in a LangGraph TypeScript app. You need this when your agent keeps calling tools forever, repeats the same action, or burns tokens because the graph has no clear exit condition.
What You'll Need
- •Node.js 18+
- •TypeScript 5+
- •
@langchain/langgraph - •
@langchain/openai - •
dotenv - •An OpenAI API key in
OPENAI_API_KEY - •A project with
"type": "module"inpackage.json
Install the packages:
npm install @langchain/langgraph @langchain/openai dotenv
npm install -D typescript tsx @types/node
Create a .env file:
OPENAI_API_KEY=your_key_here
Step-by-Step
- •Start with a graph that can loop.
The simplest way to debug a loop is to reproduce one. This graph has an agent node and a tool node, and it will keep cycling until the model stops asking for tools.
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const getTime = tool(
async () => new Date().toISOString(),
{
name: "get_time",
description: "Get the current time in ISO format",
schema: z.object({}),
}
);
- •Define state and add a debug counter.
When loops happen, you want visibility into how many times each node runs. A simple stepCount field gives you a hard stop before you burn through tokens.
const State = Annotation.Root({
messages: Annotation<any[]>({
default: () => [],
reducer: (left, right) => left.concat(right),
}),
stepCount: Annotation<number>({
default: () => 0,
reducer: (left, right) => left + right,
}),
});
type GraphState = typeof State.State;
- •Build the agent and tool nodes with loop protection.
The agent decides whether to call the tool again. The guard function stops execution after three passes so you can see the loop instead of letting it run forever.
async function agentNode(state: GraphState) {
const response = await llm.bindTools([getTime]).invoke(state.messages);
return {
messages: [response],
stepCount: 1,
};
}
function shouldContinue(state: GraphState) {
const lastMessage = state.messages[state.messages.length - 1];
if (state.stepCount >= 3) return END;
if (lastMessage?.tool_calls?.length) return "tools";
return END;
}
const toolNode = new ToolNode([getTime]);
- •Wire the graph and stream each step.
Streaming is where debugging becomes practical. You can see exactly which node ran, what it returned, and where the loop repeats.
const graph = new StateGraph(State)
.addNode("agent", agentNode)
.addNode("tools", toolNode)
.addEdge(START, "agent")
.addConditionalEdges("agent", shouldContinue, {
tools: "tools",
[END]: END,
})
.addEdge("tools", "agent")
.compile();
const input = {
messages: [{ role: "user", content: "What time is it?" }],
};
for await (const event of await graph.streamEvents(input, { version: "v2" })) {
if (event.event === "on_chain_start" || event.event === "on_chain_end") {
console.log(event.name, event.event);
}
}
- •Add a direct trace of state changes.
If you only watch node names, you miss the real problem. Logging the state after each run shows whether the model is repeating tool calls or failing to produce a final answer.
const result = await graph.invoke(input);
console.log("Final step count:", result.stepCount);
console.log(
"Last message:",
result.messages[result.messages.length - 1]
);
if (result.stepCount >= 3) {
console.log("Loop guard triggered. The agent was cycling too long.");
}
- •Tighten the exit condition once you find the bug.
Most beginner loops come from one of three issues: bad prompt instructions, missing stop conditions, or tool output that keeps triggering another call. Fix the cause instead of just raising the limit.
function shouldContinueFixed(state: GraphState) {
const lastMessage = state.messages[state.messages.length - 1];
if (state.stepCount >= 5) return END;
if (!lastMessage?.tool_calls?.length) return END;
const repeatedToolCall =
state.messages.filter((m) => m?.tool_calls?.length).length > 1;
`repeatedToolCall ? END : "tools"`;
}
Testing It
Run the file with tsx and ask a question that usually triggers a tool call. If the loop is present, you should see multiple agent → tools cycles before the guard stops execution.
Then remove or relax the guard and confirm that your graph can still terminate on its own when the model returns a normal assistant message. If it never terminates without the guard, your prompt or tool schema is probably too permissive.
A good test is to ask for something trivial like current time or weather and compare the number of steps across runs. If step counts vary wildly for the same input, your agent behavior is unstable and needs tighter instructions or better conditional routing.
Next Steps
- •Add structured logging for every node with request IDs so you can trace one conversation across retries.
- •Learn how to use LangGraph checkpoints so you can replay a looping thread from an exact state.
- •Move from raw message inspection to typed state fields for tool intent, retry count, and termination reason.
Keep learning
- •The complete AI Agents Roadmap — my full 8-step breakdown
- •Free: The AI Agent Starter Kit — PDF checklist + starter code
- •Work with me — I build AI for banks and insurance companies
By Cyprian Aarons, AI Consultant at Topiax.
Want the complete 8-step roadmap?
Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.
Get the Starter Kit