AI DevelopmentTutorial

LangGraph in 2026: Build a Support Agent That Knows When to Ask

August 22, 2026
12 min read
LangGraph support agent graph with human approval step
Share:

Most AI agent tutorials end where production begins: the demo answers one question and forgets everything. A real customer-support agent needs memory across turns, tools that touch real systems, and — critically — the judgment to pause and ask a human before issuing a refund.

In this tutorial you will build exactly that with LangGraph: a support agent with a typed state graph, tool calls, checkpointed memory, a human approval step, and LangSmith tracing. Every API below is verified against the current official docs.

1. The Problem: Demos Forget, Refunds Do Not

A support chatbot over a plain LLM call has three fatal flaws. It has no memory between requests, so every message starts from zero. It cannot act, so it can only talk about refunds instead of processing them. And it cannot escalate, so the risky decision — sending money back — happens with zero oversight or gets blocked entirely.

The Most Common Mistake

Giving an agent a refund tool with no approval gate. I have seen this pattern in rescue projects more than once: the agent works great in the demo, then issues a real refund to the wrong order in week one. Autonomy without a pause button is a liability, not a feature.

LangGraph solves this with an explicit graph: nodes do the work, edges decide what happens next, a checkpointer remembers the state per conversation thread, and an interrupt pauses execution until a human approves. That is the architecture we are about to build.

2. Minimum Concepts: State, Nodes, Edges, Checkpoints

LangGraph is a low-level orchestration framework: you define an agent as a graph instead of a chain. Four ideas carry 90% of the framework. Learn these and the tutorial below will read like plain Python.

🗂️

StateGraph + TypedDict

langgraph.graph · the shared memory

One typed state object flows through every node. Message lists use a reducer (operator.add or add_messages) so turns append instead of overwrite. Nodes return partial updates, never the whole state.

🔧

ToolNode

langgraph.prebuilt · the hands

A prebuilt node that executes the model’s tool calls and returns ToolMessages. You define tools with the @tool decorator and attach them with model.bind_tools(tools). No custom executor needed.

💾

Checkpointer + thread_id

persistence · the memory

Compile with checkpointer=InMemorySaver() for local work (PostgresSaver or SqliteSaver in production) and pass config={“configurable”: {“thread_id”: …}} so each conversation resumes its own state.

interrupt + Command(resume=…)

langgraph.types · the pause button

Call interrupt(payload) inside a node to freeze execution and surface a question. Resume later with graph.invoke(Command(resume=answer), config). The node re-runs from its start, so keep pre-interrupt code idempotent.

🔀

Conditional edges

routing · the judgment

add_conditional_edges routes on state: if the last message has tool_calls go to the tools node, else END. This tiny function is the whole ReAct loop — model acts, tools respond, model continues.

🔭

LangSmith tracing

observability · the flight recorder

Set LANGSMITH_TRACING=true and every node, tool call, and token shows up as a trace. When your agent misbehaves in production, you read the trace top to bottom instead of guessing.

What We Are Building

A support agent with 4 nodes: agent (the LLM), tools (order lookup + refund), refund_approval (human gate via interrupt), and END. Memory via InMemorySaver, routing via should_continue, and full tracing in LangSmith.

3. Steps 1–2: Tools and State

Install the packages, define two support tools, and bind them to the model. The refund tool does not execute directly — it will be gated by the approval node later. State is a TypedDict with an appended message list plus one domain field.

pip install langgraph langchain-anthropic
export ANTHROPIC_API_KEY="sk-..."
export LANGSMITH_TRACING="true"   # flight recorder on

from langchain.tools import tool
from langchain.chat_models import init_chat_model

model = init_chat_model("claude-sonnet-4-6", temperature=0)

@tool
def lookup_order(order_id: str) -> str:
    """Look up an order by ID. Returns status and total."""
    return f"Order {order_id}: shipped, total $84.20"

@tool
def issue_refund(order_id: str, amount: float) -> str:
    """Issue a refund. Only runs after human approval."""
    return f"Refunded ${amount:.2f} to order {order_id}"

tools = [lookup_order, issue_refund]
model_with_tools = model.bind_tools(tools)

from typing_extensions import TypedDict, Annotated
from langchain_core.messages import AnyMessage
import operator

class SupportState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]
    customer_tier: str

Why operator.add Matters

Without the Annotated reducer, each node’s return would overwrite the message history. With operator.add, every node appends and the full conversation survives the whole run — including across interrupts.

4. Steps 3–4: Nodes, Edges, and the Graph

Two nodes (agent + prebuilt ToolNode), one routing function, four edges. The agent node calls the bound model; should_continue sends tool calls to execution and plain answers to END. Compile once without a checkpointer to test the loop, then add memory in the next step.

from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode
from typing import Literal

def agent_node(state: SupportState):
    reply = model_with_tools.invoke(state["messages"])
    return {"messages": [reply]}

def should_continue(state: SupportState) -> Literal["tools", END]:
    if state["messages"][-1].tool_calls:
        return "tools"
    return END

builder = StateGraph(SupportState)
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, ["tools", END])
builder.add_edge("tools", "agent")

graph = builder.compile()  # memory comes next

from langchain_core.messages import HumanMessage
out = graph.invoke({
    "messages": [HumanMessage(content="Where is order 8821?")],
    "customer_tier": "pro",
})
print(out["messages"][-1].content)

Test Before Adding Memory

Run the memoryless graph first with a lookup question. If the ReAct loop works here, any later bug lives in checkpointing or interrupts — you just cut your debugging surface in half.

5. Steps 5–6: Memory, Approval Gate, and Tracing

Now recompile with a checkpointer, insert the refund_approval node between tools and execution, and resume with Command. Refunds over $50 pause for a human; everything else flows through. LangSmith records both halves of the paused run automatically.

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command

def refund_approval(state: SupportState):
    last = state["messages"][-1]
    calls = [c for c in last.tool_calls if c["name"] == "issue_refund"]
    if not calls or calls[0]["args"].get("amount", 0) <= 50:
        return {"messages": []}  # small amounts pass through
    ok = interrupt({  # pauses here, payload shown to reviewer
        "question": "Approve this refund?",
        "order": calls[0]["args"],
        "tier": state["customer_tier"],
    })
    if not ok:
        return {"messages": [HumanMessage(content="Refund declined by reviewer.")]}
    return {"messages": []}

builder.add_node("refund_approval", refund_approval)
builder.add_edge("tools", "refund_approval")
# rewire: tools -> approval -> agent (rebuild + recompile)
checkpointer = InMemorySaver()  # SqliteSaver / PostgresSaver in prod
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "support-123"}}
first = graph.invoke(
    {"messages": [HumanMessage(content="Refund $340 to order 8821")],
     "customer_tier": "pro"},
    config=config,
)
print(first.get("__interrupt__"))  # paused, waiting for a human
final = graph.invoke(Command(resume=True), config=config)  # approve
print(final["messages"][-1].content)

Production Checklist

Swap InMemorySaver for PostgresSaver (it survives restarts), keep thread_id values under 255 chars, never wrap interrupt() in a bare try/except, and run refunds as idempotent operations — the approval node re-runs from its start on resume.

6. Common Errors (and the One-Line Fix for Each)

These four failures account for most LangGraph support threads I have seen. Each has a precise cause and a one-line fix — memorize them before you ship.

🧠

Graph forgets everything on turn two

Cause: compiled without a checkpointer, or invoked without a thread_id. Fix: compile with checkpointer=… and always pass config={“configurable”: {“thread_id”: …}} — same thread resumes, new thread starts fresh.

🔁

Infinite loop between agent and tools

Cause: should_continue never returns END — usually a tool error that makes the model retry forever. Fix: set a recursion_limit on invoke and log tool exceptions as ToolMessages so the model sees the failure.

💥

interrupt() never pauses — the graph blows past it

Cause: interrupt() wrapped in a bare try/except that swallows its control-flow exception, or no checkpointer attached. Fix: keep interrupt() outside try/except blocks and compile with a checkpointer.

📦

Duplicate refunds after approval

Cause: non-idempotent side effect before interrupt() re-executes on resume. Fix: move writes after the interrupt call, or make them idempotent (upsert by idempotency key, never blind insert).

7. When LangGraph Pays Off — and When It Does Not

LangGraph is overhead for single-shot prompts and a superpower for stateful workflows. Use this rule to decide before you commit a new graph to your codebase.

✅ Reach for LangGraph

  • Multi-turn support, onboarding, or ops workflows with memory
  • Tools with side effects that need approval gates or audits
  • Long runs that must survive restarts, timeouts, and handoffs
  • Debugging via LangSmith traces instead of print statements
  • Routing between specialists (triage → billing → technical)

❌ Skip the graph

  • One question, one answer — call the chat model directly
  • Static pipelines with no branching or retries
  • Prototypes where a simple agent abstraction ships faster
  • State with a dozen ad-hoc fields nobody owns or documents

Golden rule

If the task needs memory, branching, or a human pause button, model it as a graph. If it needs none of the three, a graph is ceremony. I review this with every team before we add the first node.

Sources

Every API in this tutorial is taken from the official docs and current community guides. Verify before you ship — frameworks move fast.

Conclusion

You now have a support agent that remembers the conversation, looks up orders, and refuses to move money without a human saying yes. That loop — state, tools, checkpoint, interrupt, trace — is the template behind most production agents I build or rescue.

Next step: point the same graph at your own tools (Zendesk lookup, Stripe refund in test mode), swap InMemorySaver for PostgresSaver, and watch the first ten traces in LangSmith. The traces will teach you more than any tutorial — including this one.

What You Built: Recap

Graph

  • • StateGraph + ToolNode
  • • should_continue router
  • • refund_approval gate

Memory & Safety

  • • InMemorySaver → Postgres
  • • thread_id per conversation
  • • interrupt + resume

Observability

  • • LANGSMITH_TRACING=true
  • • Trace every tool call
  • • Audit approvals
Diego Rodriguez

Diego Rodriguez

Senior Full-Stack & AI Engineer

Diego has 10+ years of experience building production-grade AI-powered applications, from LLM orchestration and RAG pipelines to ML-driven risk detection and algorithmic trading systems.

Learn more about Diego