Agentic AI · Module 22 of 41 · 10 concepts · about 17 min

Building with Frameworks

The from-scratch agent rebuilt with a graph framework, one capability at a time: tool execution, a graph workflow with routing, short-term and long-term memory, a human approval that pauses and resumes, and a supervisor with worker agents.

At work: What moving from prototype to product looks like: the tools and prompt stay the same, and the loop is replaced by a structure that saves state, pauses for people and coordinates more than one agent.

The plan: from a loop to a graph

What it is

We rebuild the Kirana Kart agent in five steps, each adding one capability the hand-written loop lacked:

  • Tool execution: the same tools, declared for the framework.
  • A graph workflow: the loop drawn as nodes and edges, with a routing step in front.
  • Memory: saved conversation state, and a long-term store for customer facts.
  • Human approval: a pause before large refunds.
  • Multi-agent: a supervisor that delegates to specialist agents.

Install with pip install langgraph langchain langchain-anthropic and pin the versions.

Why it matters

Building the same agent a second way shows what the framework is actually doing.

  • Every node here corresponds to a few lines of the module 10 loop.
  • The parts that were hard by hand (resume after a crash, wait a day for approval) become configuration.
  • The parts that were easy by hand (seeing the exact request) now need tracing turned on.

In the real world: A team ports its hundred-line agent to a graph in two days; most of the time goes on deciding where the node boundaries should be, not on code.

Visual example

Target

Six nodes. Only two of them call a model.

The graph we are building

  1. routeSmall model: refund, question, other
  2. agentRequest a tool, or answer
  3. approveLarge refunds wait for a supervisor
  4. toolsRequested tools run
  5. rememberLasting facts saved
  6. replyChecked and returned

Porting the hand-written loop line by line into one node. Split it where you would want to observe, retry or interrupt: the model call, the tool run, the approval.

Tool execution with a framework

What it is

Tools are still plain functions. The framework needs three things from each:

  • the function, decorated so its name, description and schema are built from its signature and docstring;
  • the list of tools bound to the model, so their definitions are sent with every call;
  • a tool node that runs whatever tool calls the model's last message contains and returns the results as messages.

Why it matters

This is the executor of module 3, supplied for you.

  • The prebuilt tool node runs parallel calls together and turns exceptions into error results.
  • Your rules still live inside the functions: the framework does not know your refund policy.
  • Identity still comes from outside the model: here from the run's configuration, not from an argument the model fills in.

In the real world: A team's tools moved from their hand-written agent to the framework with one decorator each and no change to the function bodies or their tests.

from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode

@tool
def get_order(order_id: int, config: RunnableConfig) -> str:
    """Look up one of the customer's orders: total, delivery fee, how late it
    arrived. Use before any claim about an order."""
    customer = config["configurable"]["customer_id"]      # from the session, not the model
    return lookup_order(order_id, customer)

@tool
def refund_item(order_id: int, amount: int, config: RunnableConfig) -> str:
    """Refund part of an order to the customer's wallet. Use only after
    get_order confirms the complaint. Amount in rupees."""
    customer = config["configurable"]["customer_id"]
    return issue_refund(order_id, amount, customer)        # enforces its own limits

TOOLS = [get_order, refund_item]
model = init_chat_model("anthropic:claude-opus-5-5").bind_tools(TOOLS)
tool_node = ToolNode(TOOLS)            # runs the tool calls in the last message

Assuming the framework validates business rules because it validates types. It checks that amount is an integer. Only your function knows that it must not exceed the order total.

A graph workflow with conditional routing

What it is

The loop becomes a graph over a typed state:

  • the state holds the messages, the route and a step counter;
  • the route node classifies the request with a small model and structured output;
  • the agent node calls the model with tools;
  • a conditional edge after the agent chooses: tools, approval, or finish;
  • the edge from tools back to agent is the loop.

Why it matters

Compare with module 10: the while loop is now the cycle agent → tools → agent, and the if stop_reason checks are the conditional edge.

  • The step limit is a line in the routing function, and the framework also enforces its own recursion limit.
  • The router sends simple questions down a cheaper path.
  • The whole structure can be drawn from the code, which is the documentation.

In the real world: The first diagram a team generates from its graph shows a path from 'agent' straight to 'refund' that nobody intended; it is fixed before the code ever runs in production.

from typing import Annotated, Literal, TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages

class State(TypedDict):
    messages: Annotated[list, add_messages]      # reducer: append, do not replace
    route: str
    steps: int

def route(state: State) -> dict:
    verdict = small_model.with_structured_output(Route).invoke(state["messages"][-1].content)
    return {"route": verdict.category, "steps": 0}

def agent(state: State) -> dict:
    reply = model.invoke([SYSTEM_MESSAGE, *state["messages"]])
    return {"messages": [reply], "steps": state["steps"] + 1}

def after_agent(state: State) -> Literal["tools", "approve", "reply"]:
    last = state["messages"][-1]
    if not last.tool_calls or state["steps"] >= 8:           # answer, or the step limit
        return "reply"
    needs_person = any(c["name"] == "refund_item" and c["args"]["amount"] > 500
                       for c in last.tool_calls)
    return "approve" if needs_person else "tools"

graph = StateGraph(State)
graph.add_node("route", route)
graph.add_node("agent", agent)
graph.add_node("tools", tool_node)
graph.add_node("approve", approve)               # defined in the approval card
graph.add_node("reply", reply)

graph.add_edge(START, "route")
graph.add_conditional_edges("route", lambda s: "reply" if s["route"] == "other" else "agent",
                            ["agent", "reply"])
graph.add_conditional_edges("agent", after_agent, ["tools", "approve", "reply"])
graph.add_edge("tools", "agent")                 # the agent loop
graph.add_edge("reply", END)

Letting the model decide whether approval is needed. Here after_agent reads the amount from the tool call in code. The model cannot route around it.

Short-term memory with a checkpointer

What it is

Compile the graph with a checkpointer and every step's state is saved under a thread id:

  • calling the graph again with the same thread id continues the conversation: earlier messages are already in the state;
  • a different thread id starts fresh;
  • after a crash, the run resumes from the last saved step;
  • the saved history can be listed and inspected, step by step.

Why it matters

This is module 6's checkpointing with no code of your own.

  • Conversation memory, crash recovery and pause-and-resume are all the same feature.
  • The in-memory saver is for tests; production uses a database-backed one so any server can load any thread.
  • Saved state grows with the conversation, so compaction (module 12) is still your job, usually as a node or middleware that summarises old messages.

In the real world: A support product derives the thread id from the ticket number; a customer returning to a week-old ticket continues exactly where they left off, on whichever server answers.

from langgraph.checkpoint.memory import InMemorySaver
# Production: a database-backed saver, e.g. PostgresSaver, so any server can resume any thread.

app = graph.compile(checkpointer=InMemorySaver())

config = {"configurable": {"thread_id": "ticket-4411", "customer_id": 412}}

app.invoke({"messages": [("user", "Was order 88213 late?")]}, config)
app.invoke({"messages": [("user", "Then refund the delivery fee.")]}, config)
# The second call sees the first exchange: both ran on thread ticket-4411.

for step in app.get_state_history(config):        # every checkpoint, newest first
    print(step.next, len(step.values["messages"]))

Reusing one thread id for every user 'to keep it simple'. Every customer then shares one conversation. The thread id must be unique per conversation and derived from the authenticated session.

Long-term memory with a store

What it is

Checkpoints remember one conversation. A store remembers across conversations. It is a key-value store with optional search by meaning:

  • items are saved under a namespace, such as the customer's id, so one customer's memories cannot be read for another;
  • a node reads relevant memories at the start of a turn and adds them to the context;
  • a node writes lasting facts at the end.

Why it matters

This is module 5 as a library feature, and the same judgement is needed.

  • The store does not decide what is worth remembering; your extraction step does.
  • The namespace is the isolation boundary, set by code from the session.
  • Deduplication, updating and expiry are still yours to design.

In the real world: An assistant stores 'prefers evening delivery' under the customer's namespace; using the fact's key ('delivery_preference') means a changed preference replaces the old one instead of sitting beside it.

Visual example

Two memories

One remembers the conversation. The other remembers the customer.

Checkpointer versus store
checkpointerstore
RemembersOne thread's full stateSelected facts across threads
Keyed byThread idNamespace and key
WrittenAutomatically, every stepBy your code, deliberately
ReadAutomatically, on resumeBy search or key, when relevant
ModuleState (6)Memory (5)
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore      # production: a database-backed store

def recall(state: State, config: RunnableConfig, *, store: BaseStore) -> dict:
    namespace = ("customers", str(config["configurable"]["customer_id"]))
    found = store.search(namespace, query=state["messages"][-1].content, limit=3)
    facts = "\n".join(f"- {item.value['fact']}" for item in found)
    return {"messages": [("system", f"Known about this customer:\n{facts}")]} if facts else {}

def remember(state: State, config: RunnableConfig, *, store: BaseStore) -> dict:
    namespace = ("customers", str(config["configurable"]["customer_id"]))
    for fact in extract_lasting_facts(state["messages"]):     # a small-model call
        store.put(namespace, key=fact.key, value={"fact": fact.text})   # same key overwrites
    return {}

app = graph.compile(checkpointer=InMemorySaver(), store=InMemoryStore())

Building the namespace from something the model produced. If the model can name the customer, it can be talked into naming another one. Take it from the configuration your server sets.

Human approval with interrupt and resume

What it is

The approval node uses the framework's pause mechanism:

  • interrupt(payload) saves the state and returns the payload to whoever invoked the graph;
  • the run is now stopped; nothing is consuming resources;
  • an approval screen shows the payload to a supervisor;
  • invoking the graph again on the same thread with Command(resume=decision) continues inside the node, with interrupt returning the decision.

Why it matters

This is module 15's checkpoint, with the saving and resuming handled by the checkpointer.

  • The wait can be seconds or days.
  • The decision returns to the model as the tool's result, so the model can explain a decline to the customer.
  • Because the node restarts from its top on resume, the interrupt comes before any side effect.

In the real world: A finance team's approval queue is a table of interrupted threads; approving a row calls resume on that thread, and a nightly job declines anything older than a day.

from langchain_core.messages import ToolMessage
from langgraph.types import Command, interrupt

def approve(state: State) -> Command:
    call = next(c for c in state["messages"][-1].tool_calls if c["name"] == "refund_item")
    decision = interrupt({                            # pauses here; nothing above has side effects
        "question": "Approve this refund?",
        "order_id": call["args"]["order_id"],
        "amount": call["args"]["amount"],
    })
    if decision["approved"]:
        return Command(goto="tools")                  # let the tool node run the refund
    declined = ToolMessage(tool_call_id=call["id"],
                           content=f"Declined by a supervisor: {decision['reason']}. Do not retry.")
    return Command(goto="agent", update={"messages": [declined]})

# 1. The customer's request runs until the approval node pauses it.
result = app.invoke({"messages": [("user", "Refund ₹1,800 for order 88213")]}, config)
print(result["__interrupt__"])                        # payload for the approval screen

# 2. Later, the supervisor's decision resumes the same thread.
app.invoke(Command(resume={"approved": False, "reason": "order was on time"}), config)

Testing only the 'approved' path. Run decline, and run 'nobody answers': a scheduled job should resume stale threads with a decline after your timeout, or they wait for ever.

A multi-agent architecture: supervisor and workers

What it is

To add specialists, give each its own agent and expose them to a supervisor as tools:

  • each worker is a small agent with its own prompt and tools;
  • a wrapper tool takes a brief, runs the worker in a fresh context, and returns only its final answer;
  • the supervisor is an agent whose only tools are these wrappers;
  • the supervisor itself is one node in the larger graph, or the whole graph.

Why it matters

This is module 17's supervisor design in a dozen lines.

  • Workers' intermediate steps never enter the supervisor's context, which keeps it small.
  • Each worker can use a cheaper model.
  • Because a worker is 'just a tool', everything about tool descriptions applies: the description must say when to use it and what to put in the brief.
  • Independent worker calls requested in one turn run in parallel.

In the real world: A support platform's supervisor runs the policy and orders specialists in the same turn; the customer's wait falls by a third and the supervisor's context holds two short answers instead of eleven tool results.

from langchain.agents import create_agent

policy_agent = create_agent(
    model="anthropic:claude-haiku-4-5",
    tools=[search_help_centre],
    system_prompt="Find the exact policy for the question. Quote it and give its title.",
)
orders_agent = create_agent(
    model="anthropic:claude-haiku-4-5",
    tools=[get_order, get_recent_orders],
    system_prompt="Answer questions about this customer's orders from the order records only.",
)

@tool
def ask_policy_specialist(brief: str) -> str:
    """Find what Kirana Kart's policy says. Give a self-contained brief: the
    situation and the exact question. The specialist sees nothing else."""
    result = policy_agent.invoke({"messages": [("user", brief)]})     # fresh context
    return result["messages"][-1].content                              # only the answer returns

@tool
def ask_orders_specialist(brief: str, config: RunnableConfig) -> str:
    """Look up facts about the customer's orders. Include the order id if known."""
    result = orders_agent.invoke({"messages": [("user", brief)]}, config)
    return result["messages"][-1].content

supervisor = create_agent(
    model="anthropic:claude-opus-5-5",
    tools=[ask_policy_specialist, ask_orders_specialist, refund_item],
    system_prompt="Resolve the customer's request. Use the specialists for facts; "
                  "brief them fully. Decide and act yourself.",
    checkpointer=InMemorySaver(),
)

Passing the supervisor's whole message list to each worker. That recreates the shared-transcript problem. The wrapper sends a brief and returns an answer; nothing else crosses.

Practise it: Design a Multi-Agent Data Analyst

Running, tracing and testing the graph

What it is

Before trusting the graph, do four things:

  • Draw it: generate the diagram from the compiled graph and check every path.
  • Stream it: watch node-by-node updates as it runs.
  • Trace it: record every model request, tool call and state change (module 25).
  • Test it: run fixed cases against fake tools and assert on the end state and the path taken.

Why it matters

A framework adds structure, and structure is testable.

  • Each node is a function you can unit-test with a hand-made state.
  • The routing function is plain code: test it exhaustively with no model at all.
  • End-to-end tests assert that a ₹1,800 refund reaches the approval node, not merely that the reply sounds right.
  • The evaluation set should live outside the framework, so the same cases can judge a rewrite.

In the real world: A team's suite has forty fast tests on routing and approval that need no model and twelve slow end-to-end cases; the fast ones catch nine regressions in ten.

Visual example

Mapping

Nothing new was invented. The same parts were given names and saved state.

The hand-written agent and the graph, side by side
needmodule 10, by handmodule 22, with a graph
Run toolsA dictionary and a for loopToolNode
The loopwhile TrueThe cycle agent → tools → agent
Stopif steps >= MAXA conditional edge, plus a recursion limit
Conversation memoryA list in memoryCheckpointer and thread id
Survive a crashNot supportedResume from the last checkpoint
ApprovalNot supportedinterrupt and Command(resume=...)
Customer memoryNot supportedStore with a per-customer namespace
SpecialistsNot supportedWorker agents wrapped as tools
print(app.get_graph().draw_mermaid())                 # paste into any Mermaid viewer

for update in app.stream({"messages": [("user", "Refund ₹300 for order 88213")]},
                         config, stream_mode="updates"):
    print(update)                                     # {'route': {...}} then {'agent': {...}} ...

def test_large_refund_needs_approval():
    last = ai_message(tool_calls=[{"name": "refund_item", "id": "1",
                                   "args": {"order_id": 88213, "amount": 1800}}])
    assert after_agent({"messages": [last], "route": "refund", "steps": 1}) == "approve"

def test_small_refund_does_not():
    last = ai_message(tool_calls=[{"name": "refund_item", "id": "1",
                                   "args": {"order_id": 88213, "amount": 300}}])
    assert after_agent({"messages": [last], "route": "refund", "steps": 1}) == "tools"

Testing only through the model. The routing and approval logic are deterministic code; test them directly, so a prompt change cannot silently break a safety path.

Interview questions: framework-based implementation

What it is

The questions interviewers ask about framework-based implementation, from the first screening call to the senior round. Cover the answer, say yours aloud, then compare.

Why it matters

Reading an idea and explaining it are different skills.

  • Each answer below is the two or three sentences an interviewer is listening for.
  • The reasoning behind each one is in the cards above. If an answer surprises you, go back to that card.

In the real world: A take-home asks for a support agent 'in any framework'; the submissions that stand out have a routing function with unit tests and an approval path that is exercised in the test suite.

Q: How would you structure an agent in LangGraph?

Define a typed state with a message list and counters; nodes for the model call, tool execution and any fixed steps; conditional edges for tool use, approval and finishing; an edge from tools back to the model as the loop; compile with a checkpointer.

Q: Where does the agent loop go in a graph?

It is a cycle: the model node, a conditional edge to the tool node when tools are requested, and an edge back. The exit is the conditional edge choosing the end when there are no tool calls or a limit is reached.

Q: How is memory handled?

Short-term memory is the thread's checkpointed state, continued by reusing the thread id. Long-term memory is a separate store, namespaced per user, read and written by nodes.

Q: How do you add human approval?

A node calls interrupt with what needs deciding; the state is saved and the call returns. The decision arrives as a resume command on the same thread. The trigger is code on the tool call's arguments, and the node has no side effects before the interrupt.

Q: How do you build a supervisor with workers?

Create each worker as its own agent, wrap each in a tool that takes a brief and returns only the final answer, and give the supervisor those tools. Workers run in fresh contexts and can use smaller models.

Q: How do you pass the user's identity to tools?

Through the run configuration set by the server, read inside the tool. It is never a tool argument, so the model cannot supply or change it.

Q: How do you test a graph-based agent?

Unit-test nodes and routing functions with hand-made states and no model; run end-to-end cases against fake tools asserting on end state and path; keep the evaluation set independent of the framework.

Q: What did the framework not do for you?

Tool design, business rules, what to remember, context compaction, evaluation and security. It supplied structure and durability, not judgement.

Exercise: create a LangGraph agent with memory and approval

What it is

A short build that uses this module's ideas. Write it yourself before looking anything up; the checks at the end tell you whether it works.

Why it matters

An idea you have only read fades in a week.

  • Building it once, even in fifty lines, is what makes it yours.
  • Interviewers can tell the difference between someone who has read about a loop and someone who has debugged one.

In the real world: A fintech's production support agent is this graph with eleven nodes instead of six, the same checkpointer on Postgres, and the approval queue wired to its back-office tool.

Your task. Build the graph from this module end to end and prove each capability with a test or a demonstration.

Steps

  1. Install pinned versions and build the state, the tools and the agent-tools cycle. Run the late-delivery task.
  2. Add the routing node and send 'thank you' down the short path.
  3. Compile with a checkpointer. Show a second message on the same thread sees the first, and a new thread does not.
  4. Add the approval node. Show a ₹300 refund runs straight through and a ₹1,800 one pauses; resume it with approve and with decline.
  5. Add a store, a recall node and a remember node. Tell the agent a preference in one thread and show it is used in another thread for the same customer and not for a different customer.
  6. Wrap a policy specialist and an orders specialist as tools for a supervisor.
  7. Write unit tests for after_agent and draw the graph.

You are done when

  • The generated diagram matches the six nodes in this module.
  • Killing the process during a tool call and re-invoking the same thread continues the run.
  • Routing and approval are covered by tests that call no model.
  • No tool takes the customer id as a model-supplied argument.

Stretch. Rebuild the triage and hand-off part in a hand-off-based SDK and list what was easier and what was harder.

Practise it: Design a Multi-Agent Incident Response System