TechByteByByte

Capstone: Building the Enterprise Customer Resolution System

The final module — one complete, realistic, production-shaped application, built file by file, using every single mechanism this entire course has taught: state, routing, Send, reducers, subgraphs, interrupts, checkpointing, and tests.

#LangGraph#Capstone#Production

Every module in this course built one real capability. This final module builds nothing new. It assembles everything — state, nodes, conditional edges, reducers, Command, Send, subgraphs, interrupts, checkpointing, threads, and tests — into one complete, realistic application, exactly the shape a genuine, production customer-resolution system actually takes.

The architecture

flowchart TD
    START --> understand
    understand --> classify
    classify -->|billing| billing_subgraph
    classify -->|technical| technical_subgraph
    billing_subgraph --> risk_check
    technical_subgraph --> risk_check
    risk_check -->|high risk| approval
    risk_check -->|low risk| execute
    approval -->|approved| execute
    approval -->|rejected| notify_rejection
    execute --> END
    notify_rejection --> END

Project structure

customer-agent/
├── app/
│   ├── graph.py
│   ├── state.py
│   ├── nodes/
│   │   ├── __init__.py
│   │   ├── classify.py
│   │   ├── billing.py
│   │   ├── technical.py
│   │   └── approval.py
│   ├── tools/
│   │   ├── __init__.py
│   │   └── customer_tools.py
│   ├── config.py
│   └── main.py
├── tests/
│   ├── test_nodes.py
│   └── test_graph.py
├── .env.example
└── requirements.txt

config.py — recall Module 3 (LangChain course)

from dotenv import load_dotenv
import os

load_dotenv()

OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
if not OPENAI_API_KEY:
    raise ValueError("Missing OPENAI_API_KEY — check your .env file.")

state.py — recall Modules 3, 11, 12

from typing import TypedDict, Annotated, Literal
import operator
from langgraph.graph.message import add_messages

class ResolutionState(TypedDict):
    messages: Annotated[list, add_messages]
    query: str
    category: Literal["billing", "technical", ""]
    findings: Annotated[list[str], operator.add]
    risk_level: Literal["low", "high", ""]
    approved: bool
    resolution: str

Notice findings uses operator.add, exactly like Module 23’s shared multi-agent state — different specialist subgraphs will genuinely accumulate their own real findings into this one, shared field.

tools/customer_tools.py — recall Module 12 (LangChain course)

from langchain.tools import tool

ORDERS = {"o_1": {"customer_id": "c_1", "amount": 45.0, "days_since_purchase": 12}}

@tool
def get_order(order_id: str) -> str:
    """Look up order details by order ID."""
    order = ORDERS.get(order_id)
    return str(order) if order else f"No order found with ID {order_id}."

@tool
def retry_payment(order_id: str) -> str:
    """Retry a failed payment for an order."""
    return f"Payment for order {order_id} retried successfully."

@tool
def issue_refund(order_id: str, amount: float) -> str:
    """Issue a refund for an order."""
    return f"Refund of ${amount} issued for order {order_id}."

nodes/classify.py — recall Module 8

from langchain.chat_models import init_chat_model
from pydantic import BaseModel
from typing import Literal

class Classification(BaseModel):
    category: Literal["billing", "technical"]

model = init_chat_model("openai:gpt-4o-mini").with_structured_output(Classification)

def understand(state: dict) -> dict:
    return {"query": state["messages"][-1].content}

def classify(state: dict) -> dict:
    result = model.invoke(f"Classify this customer request: {state['query']}")
    return {"category": result.category}

def route_by_category(state: dict) -> str:
    return state["category"]

nodes/billing.py — recall Modules 22, 25

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from app.tools.customer_tools import get_order, retry_payment

class BillingState(TypedDict):
    query: str
    findings: list[str]

def check_order(state: BillingState) -> dict:
    try:
        result = get_order.invoke({"order_id": "o_1"})
        return {"findings": [f"Order lookup: {result}"]}
    except Exception as e:
        return {"findings": [f"Order lookup failed: {e}"]}

def attempt_retry(state: BillingState) -> dict:
    result = retry_payment.invoke({"order_id": "o_1"})
    return {"findings": [result]}

billing_builder = StateGraph(BillingState)
billing_builder.add_node("check_order", check_order)
billing_builder.add_node("attempt_retry", attempt_retry)
billing_builder.add_edge(START, "check_order")
billing_builder.add_edge("check_order", "attempt_retry")
billing_builder.add_edge("attempt_retry", END)

billing_subgraph = billing_builder.compile()

nodes/technical.py — a second, parallel specialist

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class TechnicalState(TypedDict):
    query: str
    findings: list[str]

def diagnose(state: TechnicalState) -> dict:
    return {"findings": [f"Diagnosis complete for: {state['query']}"]}

technical_builder = StateGraph(TechnicalState)
technical_builder.add_node("diagnose", diagnose)
technical_builder.add_edge(START, "diagnose")
technical_builder.add_edge("diagnose", END)

technical_subgraph = technical_builder.compile()

nodes/approval.py — recall Modules 19, 20

from langgraph.types import interrupt

def assess_risk(state: dict) -> dict:
    return {"risk_level": "high" if "refund" in state["query"].lower() else "low"}

def request_approval(state: dict) -> dict:
    decision = interrupt({"question": f"Approve action for: {state['query']}?"})
    return {"approved": decision}

def route_after_risk(state: dict) -> str:
    return "request_approval" if state["risk_level"] == "high" else "execute"

def route_after_approval(state: dict) -> str:
    return "execute" if state["approved"] else "notify_rejection"

def execute(state: dict) -> dict:
    return {"resolution": f"Resolved: {'; '.join(state['findings'])}"}

def notify_rejection(state: dict) -> dict:
    return {"resolution": "Action was reviewed and not approved."}

graph.py — assembling everything

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from app.state import ResolutionState
from app.nodes.classify import understand, classify, route_by_category
from app.nodes.billing import billing_subgraph
from app.nodes.technical import technical_subgraph
from app.nodes.approval import (
    assess_risk, request_approval, execute, notify_rejection,
    route_after_risk, route_after_approval,
)

builder = StateGraph(ResolutionState)
builder.add_node("understand", understand)
builder.add_node("classify", classify)
builder.add_node("billing", billing_subgraph)
builder.add_node("technical", technical_subgraph)
builder.add_node("assess_risk", assess_risk)
builder.add_node("request_approval", request_approval)
builder.add_node("execute", execute)
builder.add_node("notify_rejection", notify_rejection)

builder.add_edge(START, "understand")
builder.add_edge("understand", "classify")
builder.add_conditional_edges("classify", route_by_category, {"billing": "billing", "technical": "technical"})
builder.add_edge("billing", "assess_risk")
builder.add_edge("technical", "assess_risk")
builder.add_conditional_edges("assess_risk", route_after_risk, {"request_approval": "request_approval", "execute": "execute"})
builder.add_conditional_edges("request_approval", route_after_approval, {"execute": "execute", "notify_rejection": "notify_rejection"})
builder.add_edge("execute", END)
builder.add_edge("notify_rejection", END)

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

Read this against the module’s own opening diagram directly — every real edge here maps to exactly one arrow in that diagram. billing and technical are real, independently testable subgraphs from Module 22. assess_risk and request_approval form the exact real interrupt pattern from Modules 19-20. Nothing here is new; it’s every mechanism from this course, wired together.

main.py — running it end to end

from app.graph import graph
from langgraph.types import Command

def main():
    config = {"configurable": {"thread_id": "customer-session-1"}}

    result = graph.invoke(
        {"messages": [{"role": "user", "content": "I need a refund for order o_1"}], "findings": [], "category": "", "risk_level": "", "approved": False, "resolution": "", "query": ""},
        config=config,
    )

    if "__interrupt__" in result:
        print("Paused for human approval...")
        result = graph.invoke(Command(resume=True), config=config)

    print(result["resolution"])

if __name__ == "__main__":
    main()

tests/test_nodes.py — recall Module 27

from app.nodes.approval import route_after_risk, route_after_approval

def test_route_after_risk_high():
    assert route_after_risk({"risk_level": "high"}) == "request_approval"

def test_route_after_approval_rejected():
    assert route_after_approval({"approved": False}) == "notify_rejection"

tests/test_graph.py

from langgraph.types import Command
from app.graph import graph

def test_full_flow_pauses_and_resumes_for_refund():
    config = {"configurable": {"thread_id": "test-thread"}}
    result = graph.invoke(
        {"messages": [{"role": "user", "content": "I need a refund for order o_1"}], "findings": [], "category": "", "risk_level": "", "approved": False, "resolution": "", "query": ""},
        config=config,
    )
    assert "__interrupt__" in result

    final = graph.invoke(Command(resume=True), config=config)
    assert "Resolved" in final["resolution"]

Tracing the real, complete data flow

A real customer message enters at understand, extracting the raw query. classify genuinely reads it and routes to billing or technical — each a real, independently testable subgraph, whose own real findings accumulate into the shared findings field via Module 11’s reducer. assess_risk genuinely decides whether this specific resolution is high-stakes enough to need request_approval’s real interrupt() — and if the graph pauses there, main.py’s own real check for "__interrupt__" is what tells your application a human is genuinely needed before execute can run. Every one of these steps is checkpointed, meaning this entire flow could genuinely pause for a human review that takes two real days, and resume exactly where it left off, on a completely different machine, without losing a single piece of real progress.

Common mistakes worth avoiding, at the level of a real, complete application

Forgetting that billing and technical subgraphs need field names aligned with the parent’s shared state. Recall Module 22’s real distinction — BillingState and TechnicalState both share query and findings with ResolutionState deliberately, so they can be added directly as nodes without a translation wrapper. Changing a subgraph’s field names without updating this alignment silently breaks the handoff.

Deploying this exact application with InMemorySaver, unchanged. Recall Module 28’s own direct warning — the version in this module’s graph.py is genuinely correct for learning and local testing; a real deployment needs PostgresSaver in its place before it ever serves a real, paying customer.

Skipping the "__interrupt__" check in main.py. Recall Module 19’s own core lesson — if main.py didn’t check for it explicitly, a paused, high-risk refund would appear to simply hang, when it’s actually working exactly as designed, waiting for a real human who hasn’t been notified to look.

Closing this entire course

You began Module 1 watching a LangChain agent’s system prompt ask, uselessly, for a human to “pause and wait.” Twenty-nine modules later, you’ve built a real system where that pause is a structural guarantee — checkpointed, resumable, testable, and genuinely production-shaped.

The honest measure of this course was never “can you name LangGraph’s APIs.” It’s this: given a real, unfamiliar agent architecture — drawn as a diagram, described in a meeting, sketched on a whiteboard — can you model its state, draw its real workflow, implement it as nodes and edges, control its routing and loops deliberately, persist its execution, pause it for a human, recover it from a real failure, compose it from genuine subgraphs, and build a reliable, tested, multi-agent application from it. That’s what you now have.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed