Route Support Tickets with LangGraph
Build a resumable support ticket router with LangGraph approval gates.
Introduction
30 Second Summary
When a support ticket mentions a locked account or suspected breach, speed matters. One rushed escalation can still send the wrong action into motion.
In this project, you will build a Python support-ticket router with LangGraph that classifies each request through visible workflow state. Urgent cases pause for a human decision before the saved workflow resumes.
What You'll Build
You enter a support request in the terminal to watch its state move from classification to safe resolution through your approval for urgent cases.
By the end of this project, you'll have:
- A terminal ticket router that accepts ticket text and prints the resulting priority plus proposed action.
- A human approval gate backed by checkpointing that pauses urgent escalation for a reviewer. The same ticket thread resumes after the decision.
- Four automated regression tests that prove the routine, unsafe, approved, and rejected routes reach the correct final states.
- Secret Mission: Let the reviewer edit the proposed action before approving an urgent escalation.
Are there any prerequisites?
You need basic Python application development experience plus Python 3.10+ in Visual Studio Code. Everything runs locally at a cost of $0 with no API key.
Before We Start
Before any hands-on work, take a moment to define the local LangGraph support-ticket router you are building. This gives you a clear reason for the human approval gate that stops sensitive actions from running automatically.
Set Up the Local Project
Your support-ticket router needs a dedicated workspace before its graph can run. Visual Studio Code keeps the project files together while you build each route.
A project-specific virtual environment keeps this project's packages separate from your other Python work. Pinning LangGraph to version 1.2.11 gives every later graph run the same environment.
In this step, get ready to:
- Prepare the langgraph-ticket-router workspace with its pinned dependency file.
- Create a .venv with Microsoft's Python extension.
- Verify the installed LangGraph package from the integrated terminal.
Create the workspace and dependency file
A workspace gives the router one clear home on your computer. Its dependency file records the exact LangGraph version that belongs in this project.
- Press Cmd+Space on macOS or the Windows key on Windows to open your search bar.
- Type Visual Studio Code into the search bar.
- Press Enter to launch Visual Studio Code.
You should now see the Visual Studio Code welcome screen.
- Select File from the top menu.
- Select Open Folder....
- Choose your Desktop as the location for the new workspace.
- Select New Folder.
- Enter langgraph-ticket-router as the folder name.
- Select Open on macOS or Select Folder on Windows.
You should see langgraph-ticket-router at the top of the Explorer sidebar. This confirms that Visual Studio Code has opened the correct workspace.
- Choose the option that trusts this folder if a Workspace Trust dialog appears.
- Select the Explorer view from the Activity Bar.
- Select New File... in the Explorer toolbar.
- Enter requirements.txt as the file name.
- Press Enter to create the file.
- Add the pinned dependency to requirements.txt by copying this line:
langgraph==1.2.11
What does this file do?
- The requirements.txt file tells Python which package this project needs.
- The == pins LangGraph to version 1.2.11 so the project uses the same release each time.
- Save requirements.txt.
- Confirm the editor shows langgraph==1.2.11 as the file's only line.
Dependency line looks different?
Remove any extra package names or version numbers from requirements.txt. Make sure the version uses two equals signs.
Ask for help with checking the pinned LangGraph dependency.
✔️ Awesome, I've got everything!
Your dependency file now pins the exact LangGraph version for this project.
ⓧ I'd like to double check the full code
- Compare requirements.txt with the complete file below:
langgraph==1.2.11
Create the virtual environment
The Python extension connects Visual Studio Code to your installed Python interpreters. It also manages the project environment that runs your code.
- Select the Extensions view from the Activity Bar.
- Enter Python in the extension search box.
Find the Python extension published by Microsoft. Its card shows whether the extension is already available.
✔️ The extension is installed
- Confirm the Python extension card names Microsoft as the publisher.
The extension is ready to create your project environment.
ⓧ The extension needs installing
- Select the Python extension published by Microsoft.
- Click Install.
- Wait for the extension card to show that installation is complete.
The extension is now ready to manage Python inside this workspace.
Python extension not installing?
- Confirm your internet connection is active.
- Restart Visual Studio Code if the extension remains pending.
Ask for help with installing the Microsoft Python extension.
Good progress. Visual Studio Code can now create a project-only Python environment.
The .venv folder stores the isolated interpreter and installed packages. Visual Studio Code also detects requirements.txt during environment creation.
- Open the Command Palette with Cmd+Shift+P on macOS or Ctrl+Shift+P on Windows.
- Select Python: Create Environment.
- Choose Venv as the environment type.
You should now see a list of the Python interpreters available on your computer.
- Select a Python interpreter running version 3.10 or newer.
- Wait for environment creation to finish.
- Confirm .venv appears in the Explorer sidebar.
- Confirm the Status Bar shows the interpreter from .venv as selected.
Your workspace now has an isolated Python environment. The dependency from requirements.txt belongs only to this project.
Verify the LangGraph installation
The package metadata provides the final proof that the selected interpreter installed the correct dependency. The package name and pinned version must both match requirements.txt.
- Open the Command Palette with Cmd+Shift+P on macOS or Ctrl+Shift+P on Windows.
- Select Terminal: Create New Terminal.
- Confirm the terminal opens inside the langgraph-ticket-router workspace.
Before you check, what package name and version do you expect the selected interpreter to report?
macOS
- Check the installed package metadata on macOS by running this command:
python -m pip show langgraph
You should see Name: langgraph and Version: 1.2.11 in the output.
Windows
- Check the installed package metadata on Windows by running this command:
py -m pip show langgraph
You should see Name: langgraph and Version: 1.2.11 in the output.
Linux
- Check the installed package metadata on Linux by running this command:
python -m pip show langgraph
You should see Name: langgraph and Version: 1.2.11 in the output.
Package details missing?
- Reselect the interpreter from the .venv environment using the interpreter control in the Status Bar.
- Create a new integrated terminal after changing the interpreter.
- Confirm requirements.txt contains langgraph==1.2.11.
Ask for help with finding LangGraph in the selected virtual environment.
Setup complete. Your workspace now has the exact LangGraph version that every later graph run expects.
Your local foundation is ready. Next, you'll build the first ticket-routing graph and expose what happens when an urgent escalation runs without human review.
Expose Unsafe Automatic Escalation
Your isolated Python environment in Visual Studio Code is ready. LangGraph 1.2.11 can now turn ticket text into visible workflow state.
Sensitive tickets are risky when a workflow can trigger escalation on its own. This first graph deliberately does exactly that so you can see the unsafe behavior in its final state.
In this step, get ready to:
- Define typed shared state for each support ticket.
- Route classified tickets through an explicit LangGraph graph.
- Compare a routine result with an urgent automatic escalation.
Define the ticket state and classifier
A LangGraph workflow passes shared state between its nodes. A deterministic classifier updates that state from a fixed set of urgent terms.
- Click New File in the Visual Studio Code Explorer sidebar inside langgraph-ticket-router.
- Enter ticket_router.py as the file name.
- Add the ticket-state foundation by pasting this code:
import argparse
import json
from typing import Literal, TypedDict
from langgraph.graph import END, START, StateGraph
URGENT_TERMS = {"security", "breach", "payment", "locked", "outage"}
class TicketState(TypedDict):
ticket_id: str
text: str
priority: str
proposed_action: str
approved: bool | None
status: str
def create_ticket(ticket_id: str, text: str) -> TicketState:
return {
"ticket_id": ticket_id,
"text": text,
"priority": "unclassified",
"proposed_action": "",
"approved": None,
"status": "new",
}
What does this code do?
- URGENT_TERMS holds the words that make a ticket sensitive.
- TicketState gives every node one typed shape for reading ticket data.
- create_ticket() produces the initial values before classification starts.
- Save ticket_router.py.
- Check the Problems indicator at the bottom of Visual Studio Code.
You should see no Python syntax errors for the ticket-state foundation.
Seeing syntax errors in the state?
Check that every field inside TicketState is indented by four spaces. Confirm that the closing brace for create_ticket() lines up with its return statement.
Help me fix the ticket-state syntax.
A classifier is a workflow node that reads the current ticket state. Its result supplies both the priority and the proposed action for the next route.
- Place your cursor after the closing brace of create_ticket() in ticket_router.py.
- Add the classifier plus its possible route outcomes by pasting this code:
def classify_ticket(state: TicketState) -> dict[str, str]:
words = set(state["text"].lower().replace("-", " ").split())
priority = "urgent" if words & URGENT_TERMS else "routine"
action = (
"Escalate to the incident response team."
if priority == "urgent"
else "Send self-service troubleshooting steps."
)
return {
"priority": priority,
"proposed_action": action,
"status": "classified",
}
def route_unsafe(state: TicketState) -> Literal["resolve_routine", "auto_escalate"]:
return "auto_escalate" if state["priority"] == "urgent" else "resolve_routine"
def resolve_routine(state: TicketState) -> dict[str, str]:
return {"status": "resolved"}
def auto_escalate(state: TicketState) -> dict[str, str]:
return {"status": "auto_escalated"}
How does classification work?
- classify_ticket() normalizes the ticket text before checking it against URGENT_TERMS.
- route_unsafe() returns the name of the next node from the classified priority.
- resolve_routine() records a routine resolution.
- auto_escalate() records an automatic urgent escalation.
- Save ticket_router.py.
- Check the Problems indicator again.
You should still see no Python syntax errors after adding the classifier and route outcomes.
Classifier showing an error?
Check that each dictionary key uses matching quotation marks. Confirm that URGENT_TERMS matches the name declared near the top of the file.
Help me debug the classifier.
Connect the unsafe graph
Graph edges decide which node runs next. The conditional edge asks route_unsafe() to choose between routine resolution and automatic escalation.
Why the Graph API?
The Graph API keeps shared state and conditional branches visible in the code. That explicit structure makes the unsafe urgent route easy to trace.
- Place your cursor after auto_escalate() in ticket_router.py.
- Add the unsafe graph builder by pasting this code:
def build_unsafe_graph():
builder = StateGraph(TicketState)
builder.add_node("classify", classify_ticket)
builder.add_node("resolve_routine", resolve_routine)
builder.add_node("auto_escalate", auto_escalate)
builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", route_unsafe)
builder.add_edge("resolve_routine", END)
builder.add_edge("auto_escalate", END)
return builder.compile()
What does the graph builder do?
- StateGraph(TicketState) creates a graph whose nodes share the ticket schema.
- add_node() registers the classifier and both possible outcomes.
- add_conditional_edges() lets the priority determine the next node.
- compile() turns the graph definition into a runnable workflow.
- Save ticket_router.py.
- Check the Problems indicator for graph-building errors.
You should see no Python syntax errors around the graph builder.
Graph names underlined?
Check that the node names passed to add_node() match the values returned by route_unsafe(). Confirm that START and END are present in the LangGraph import.
Help me fix the unsafe graph wiring.
Run routine and urgent tickets
The command-line path sends ticket text through the compiled graph. It prints the completed state as JSON so every classification decision stays visible.
- Place your cursor after build_unsafe_graph() in ticket_router.py.
- Add the graph runner plus the command-line entry point by pasting this code:
def run_unsafe_ticket(ticket_id: str, text: str) -> TicketState:
graph = build_unsafe_graph()
return graph.invoke(create_ticket(ticket_id, text))
def main() -> None:
parser = argparse.ArgumentParser(description="Route a support ticket with LangGraph.")
parser.add_argument("--ticket-id", default="ticket-001")
parser.add_argument("--text")
parser.add_argument("--unsafe-demo", action="store_true")
args = parser.parse_args()
text = args.text or input("Describe the support ticket: ")
result = run_unsafe_ticket(args.ticket_id, text)
print("\nFinal state:")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
How does the command-line path work?
- run_unsafe_ticket() builds a fresh graph before invoking it with a new ticket.
- argparse reads the ticket text and the --unsafe-demo option from the terminal.
- json.dumps() formats the final state so its fields are easy to compare.
- Save ticket_router.py.
✔️ Awesome, I've got everything!
Your unsafe router is ready. Double-check that ticket_router.py is saved before running it.
ⓧ I'd like to double check the full code
import argparse
import json
from typing import Literal, TypedDict
from langgraph.graph import END, START, StateGraph
URGENT_TERMS = {"security", "breach", "payment", "locked", "outage"}
class TicketState(TypedDict):
ticket_id: str
text: str
priority: str
proposed_action: str
approved: bool | None
status: str
def create_ticket(ticket_id: str, text: str) -> TicketState:
return {
"ticket_id": ticket_id,
"text": text,
"priority": "unclassified",
"proposed_action": "",
"approved": None,
"status": "new",
}
def classify_ticket(state: TicketState) -> dict[str, str]:
words = set(state["text"].lower().replace("-", " ").split())
priority = "urgent" if words & URGENT_TERMS else "routine"
action = (
"Escalate to the incident response team."
if priority == "urgent"
else "Send self-service troubleshooting steps."
)
return {
"priority": priority,
"proposed_action": action,
"status": "classified",
}
def route_unsafe(state: TicketState) -> Literal["resolve_routine", "auto_escalate"]:
return "auto_escalate" if state["priority"] == "urgent" else "resolve_routine"
def resolve_routine(state: TicketState) -> dict[str, str]:
return {"status": "resolved"}
def auto_escalate(state: TicketState) -> dict[str, str]:
return {"status": "auto_escalated"}
def build_unsafe_graph():
builder = StateGraph(TicketState)
builder.add_node("classify", classify_ticket)
builder.add_node("resolve_routine", resolve_routine)
builder.add_node("auto_escalate", auto_escalate)
builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", route_unsafe)
builder.add_edge("resolve_routine", END)
builder.add_edge("auto_escalate", END)
return builder.compile()
def run_unsafe_ticket(ticket_id: str, text: str) -> TicketState:
graph = build_unsafe_graph()
return graph.invoke(create_ticket(ticket_id, text))
def main() -> None:
parser = argparse.ArgumentParser(description="Route a support ticket with LangGraph.")
parser.add_argument("--ticket-id", default="ticket-001")
parser.add_argument("--text")
parser.add_argument("--unsafe-demo", action="store_true")
args = parser.parse_args()
text = args.text or input("Describe the support ticket: ")
result = run_unsafe_ticket(args.ticket_id, text)
print("\nFinal state:")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
- Run the routine-ticket demo from the integrated terminal with this command:
python ticket_router.py --unsafe-demo
What does this command do?
The command starts ticket_router.py through its unsafe demonstration path. The missing --text value makes the script ask for ticket text in the terminal.
- Enter How do I change my profile photo? at the ticket prompt.
You should see a final state containing "priority": "routine" and "status": "resolved".
Before you run the urgent ticket, do you think this graph will stop before taking the sensitive action?
- Run the verified urgent example with this command:
python ticket_router.py --unsafe-demo --text "Payment account locked after suspected breach"
What does this command test?
The ticket contains several values from URGENT_TERMS. The --text option sends that ticket directly into the unsafe graph.
You should see "priority": "urgent" and "status": "auto_escalated" in the printed state. You caught the exact risk this demo was built to expose.
The urgent ticket reached escalation without any reviewer decision. That shortfall is intentional.
Final state does not match?
Confirm that the integrated terminal is still inside langgraph-ticket-router. Check that route_unsafe() returns auto_escalate for an urgent priority.
Help me debug the unsafe ticket result.
You have exposed the gap that makes automatic escalation unsafe. Next, you will add a checkpoint-backed review gate that pauses the urgent route before it completes.
Add Human Approval and Resumable State
Your LangGraph router now exposes the risk of automatic escalation. An urgent ticket can reach a sensitive action without anyone reviewing it.
This step adds checkpointing so the workflow can remember where it paused. An interrupt creates the review boundary that blocks escalation until a person approves or rejects it.
In this step, get ready to:
- Pause urgent tickets with a JSON approval payload.
- Compile a safe graph with an in-memory checkpointer.
- Resume the same thread ID after the reviewer makes a decision.
Add the approval decision
The approval node sends ticket details through a JSON-serializable review payload. The review decision becomes a Command that updates the state before choosing the next node.
- In ticket_router.py, replace the import section at the top with this block:
import argparse
import json
from typing import Literal, TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
What do these imports unlock?
- InMemorySaver stores graph checkpoints in process memory.
- interrupt pauses a running graph with information for the reviewer.
- Command carries the review decision back into the paused graph.
- Save ticket_router.py.
- Check the editor for unresolved import warnings.
Visual Studio Code should accept all three LangGraph imports from the selected .venv.
Seeing an import warning?
Confirm that the interpreter selected in Visual Studio Code belongs to the .venv from the previous step. Check that requirements.txt still contains the pinned LangGraph dependency.
Help me diagnose unresolved LangGraph imports in my selected virtual environment.
- In ticket_router.py, find route_unsafe().
- Add the safe routing function directly below it:
def route_safe(state: TicketState) -> Literal["resolve_routine", "approval"]:
return "approval" if state["priority"] == "urgent" else "resolve_routine"
How does safe routing work?
Routine tickets still go to resolve_routine. Urgent tickets now point to an approval node that you will add next.
- Save ticket_router.py.
- Confirm that the original unsafe route still runs by using this command:
python ticket_router.py --unsafe-demo --text "Payment account locked after suspected breach"
What does this check prove?
Python loads the new routing function before running the existing unsafe graph. You should still see "priority": "urgent" followed by "status": "auto_escalated".
Did the unsafe demo stop working?
Check that route_safe() sits outside route_unsafe(). Confirm that both function definitions keep their four-space indentation.
Help me find the syntax issue in my two ticket-routing functions.
An approval interrupt needs enough context for a reviewer to make a decision. Its payload includes the ticket text plus the proposed action.
- In ticket_router.py, find auto_escalate().
- Add the approval node plus its two destination nodes directly below it:
def approval_node(
state: TicketState,
) -> Command[Literal["resolve_urgent", "reject_urgent"]]:
approved = interrupt(
{
"question": "Approve this urgent escalation?",
"ticket_id": state["ticket_id"],
"ticket": state["text"],
"proposed_action": state["proposed_action"],
}
)
return Command(
update={"approved": bool(approved)},
goto="resolve_urgent" if approved else "reject_urgent",
)
def resolve_urgent(state: TicketState) -> dict[str, str]:
return {"status": "escalated"}
def reject_urgent(state: TicketState) -> dict[str, str]:
return {"status": "rejected"}
What does the approval node do?
- interrupt() pauses the node with ticket details that JSON can represent.
- update records the reviewer decision in approved.
- goto selects resolve_urgent for approval or reject_urgent for rejection.
- Save ticket_router.py.
- Check the editor for syntax warnings around approval_node().
The approval function should now accept TicketState and return a typed Command.
Seeing a syntax warning in the approval node?
Check that the dictionary passed to interrupt() closes before the Command begins. Confirm that Literal remains in the typing import.
Help me check the brackets and return type in my approval node.
Build the checkpointed graph
A checkpointer records the graph state when execution reaches the interrupt. InMemorySaver keeps that checkpoint available while this Python process is running.
How long does the checkpoint last?
In-memory checkpoints disappear when the program exits. This keeps the project local while still demonstrating resumable execution.
- In ticket_router.py, find the end of build_unsafe_graph().
- Add the safe graph builder directly below it:
def build_safe_graph():
builder = StateGraph(TicketState)
builder.add_node("classify", classify_ticket)
builder.add_node("resolve_routine", resolve_routine)
builder.add_node("approval", approval_node)
builder.add_node("resolve_urgent", resolve_urgent)
builder.add_node("reject_urgent", reject_urgent)
builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", route_safe)
builder.add_edge("resolve_routine", END)
builder.add_edge("resolve_urgent", END)
builder.add_edge("reject_urgent", END)
return builder.compile(checkpointer=InMemorySaver())
How does the safe graph fit together?
- StateGraph uses the existing TicketState as shared state.
- route_safe sends urgent tickets to the approval node.
- InMemorySaver() gives the compiled graph the checkpoint storage required by interrupt().
- Save ticket_router.py.
- Confirm that the unsafe baseline still loads by running this command:
python ticket_router.py --unsafe-demo --text "Payment account locked after suspected breach"
What should you see?
The terminal should still print the urgent ticket with "status": "auto_escalated" through the deliberately unsafe graph. This confirms that the new safe graph compiles without removing the comparison path.
Is the graph failing to compile?
Check that every node name passed to add_node() matches the destination returned by route_safe(). Confirm that the final line passes InMemorySaver() to checkpointer.
Help me compare my safe graph node names with its routing destinations.
Resume the same ticket thread
The safe runner gives each ticket a stable thread_id so LangGraph can find its checkpoint. It uses stream_events(..., version="v3") to inspect the interrupt before resuming with Command(resume=...).
- In ticket_router.py, find run_unsafe_ticket().
- Add the safe runner directly below it:
def run_safe_ticket(
ticket_id: str,
text: str,
decision: bool | None = None,
) -> TicketState:
graph = build_safe_graph()
config = {"configurable": {"thread_id": ticket_id}}
initial = graph.stream_events(
create_ticket(ticket_id, text),
config=config,
version="v3",
)
_ = initial.output
if not initial.interrupted:
return initial.output
if decision is None:
print("\nReview required:")
print(json.dumps(initial.interrupts[0].value, indent=2))
decision = input("Approve? [y/n]: ").strip().lower() == "y"
resumed = graph.stream_events(
Command(resume=decision),
config=config,
version="v3",
)
return resumed.output
How does resumable execution work?
- config stores the ticket ID as the graph thread ID.
- initial.interrupted distinguishes an urgent pause from a completed routine route.
- initial.interrupts[0].value provides the review payload for the terminal.
- Command(resume=decision) continues the checkpointed graph with the same configuration.
- Save ticket_router.py.
- Confirm that Python loads the new safe runner by running the unsafe baseline again:
python ticket_router.py --unsafe-demo --text "Payment account locked after suspected breach"
Why run the baseline again?
Python parses run_safe_ticket() before the unsafe command reaches main(). The familiar urgent output confirms that the new runner loads successfully.
Seeing an error in the safe runner?
Check that both stream_events() calls use version="v3". Confirm that both calls receive the same config value.
Help me trace how my safe runner pauses and resumes the same LangGraph thread.
- In ticket_router.py, scroll to main().
- Find the line result = run_unsafe_ticket(args.ticket_id, text).
- Replace that line with this conditional selection:
result = (
run_unsafe_ticket(args.ticket_id, text)
if args.unsafe_demo
else run_safe_ticket(args.ticket_id, text)
)
How does the command choose a graph?
The --unsafe-demo flag keeps the deliberately unsafe route available. A normal run now uses run_safe_ticket() and its approval gate.
- Save ticket_router.py.
This run is interactive. The graph waits for your review decision before it produces the final urgent state.
- Predict whether the router reaches escalation before receiving your input:
- Run the safe urgent route with this command:
python ticket_router.py --text "Payment account locked after suspected breach"
What happens during this run?
- The classifier marks the ticket as urgent.
- The approval node prints its review payload.
- The interrupt pauses before either urgent destination runs.
- Enter y at the approval prompt.
You should see a final state containing "approved": true plus "status": "escalated". You have turned the unsafe escalation into a checkpointed decision that requires human approval.
Did the graph fail to resume?
Check that the initial stream plus the resumed stream both use config=config. Confirm that thread_id remains set to ticket_id.
If the final status is rejected, rerun the command and enter a lowercase y at the prompt.
Help me diagnose why my approval flow does not resume the same graph thread.
✔️ Awesome, I've got everything!
Great work. Save ticket_router.py once more before moving to the tests.
ⓧ I'd like to double check the full code
import argparse
import json
from typing import Literal, TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
URGENT_TERMS = {"security", "breach", "payment", "locked", "outage"}
class TicketState(TypedDict):
ticket_id: str
text: str
priority: str
proposed_action: str
approved: bool | None
status: str
def create_ticket(ticket_id: str, text: str) -> TicketState:
return {
"ticket_id": ticket_id,
"text": text,
"priority": "unclassified",
"proposed_action": "",
"approved": None,
"status": "new",
}
def classify_ticket(state: TicketState) -> dict[str, str]:
words = set(state["text"].lower().replace("-", " ").split())
priority = "urgent" if words & URGENT_TERMS else "routine"
action = (
"Escalate to the incident response team."
if priority == "urgent"
else "Send self-service troubleshooting steps."
)
return {
"priority": priority,
"proposed_action": action,
"status": "classified",
}
def route_unsafe(state: TicketState) -> Literal["resolve_routine", "auto_escalate"]:
return "auto_escalate" if state["priority"] == "urgent" else "resolve_routine"
def route_safe(state: TicketState) -> Literal["resolve_routine", "approval"]:
return "approval" if state["priority"] == "urgent" else "resolve_routine"
def resolve_routine(state: TicketState) -> dict[str, str]:
return {"status": "resolved"}
def auto_escalate(state: TicketState) -> dict[str, str]:
return {"status": "auto_escalated"}
def approval_node(
state: TicketState,
) -> Command[Literal["resolve_urgent", "reject_urgent"]]:
approved = interrupt(
{
"question": "Approve this urgent escalation?",
"ticket_id": state["ticket_id"],
"ticket": state["text"],
"proposed_action": state["proposed_action"],
}
)
return Command(
update={"approved": bool(approved)},
goto="resolve_urgent" if approved else "reject_urgent",
)
def resolve_urgent(state: TicketState) -> dict[str, str]:
return {"status": "escalated"}
def reject_urgent(state: TicketState) -> dict[str, str]:
return {"status": "rejected"}
def build_unsafe_graph():
builder = StateGraph(TicketState)
builder.add_node("classify", classify_ticket)
builder.add_node("resolve_routine", resolve_routine)
builder.add_node("auto_escalate", auto_escalate)
builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", route_unsafe)
builder.add_edge("resolve_routine", END)
builder.add_edge("auto_escalate", END)
return builder.compile()
def build_safe_graph():
builder = StateGraph(TicketState)
builder.add_node("classify", classify_ticket)
builder.add_node("resolve_routine", resolve_routine)
builder.add_node("approval", approval_node)
builder.add_node("resolve_urgent", resolve_urgent)
builder.add_node("reject_urgent", reject_urgent)
builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", route_safe)
builder.add_edge("resolve_routine", END)
builder.add_edge("resolve_urgent", END)
builder.add_edge("reject_urgent", END)
return builder.compile(checkpointer=InMemorySaver())
def run_unsafe_ticket(ticket_id: str, text: str) -> TicketState:
graph = build_unsafe_graph()
return graph.invoke(create_ticket(ticket_id, text))
def run_safe_ticket(
ticket_id: str,
text: str,
decision: bool | None = None,
) -> TicketState:
graph = build_safe_graph()
config = {"configurable": {"thread_id": ticket_id}}
initial = graph.stream_events(
create_ticket(ticket_id, text),
config=config,
version="v3",
)
_ = initial.output
if not initial.interrupted:
return initial.output
if decision is None:
print("\nReview required:")
print(json.dumps(initial.interrupts[0].value, indent=2))
decision = input("Approve? [y/n]: ").strip().lower() == "y"
resumed = graph.stream_events(
Command(resume=decision),
config=config,
version="v3",
)
return resumed.output
def main() -> None:
parser = argparse.ArgumentParser(description="Route a support ticket with LangGraph.")
parser.add_argument("--ticket-id", default="ticket-001")
parser.add_argument("--text")
parser.add_argument("--unsafe-demo", action="store_true")
args = parser.parse_args()
text = args.text or input("Describe the support ticket: ")
result = (
run_unsafe_ticket(args.ticket_id, text)
if args.unsafe_demo
else run_safe_ticket(args.ticket_id, text)
)
print("\nFinal state:")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
What should the completed file contain?
The completed file keeps the unsafe comparison graph. It also adds the approval node plus the checkpointed safe runner used by normal commands.
Your router now pauses urgent tickets before escalation and resumes the same checkpointed thread after review. Next, you will lock that behavior in with automated route tests.
Test Every Route
Your LangGraph router now pauses urgent tickets for a decision. It resumes the same thread after the reviewer approves or rejects the action.
Automated tests prove that this approval gate preserves routine routing. They also confirm that every human decision reaches its intended final state.
In this step, get ready to:
- Capture unsafe automatic escalation in a regression test.
- Cover every safe route with focused assertions.
- Run all four tests as one complete suite.
Create the unsafe-route regression test
A regression test records behavior that future changes must preserve or expose clearly. The first test captures the unsafe automatic escalation from earlier so the contrast with the reviewed path stays visible.
- In the file sidebar of Visual Studio Code, click the new-file icon beside the langgraph-ticket-router folder.
- Enter test_ticket_router.py as the file name.
- Add the Python unittest imports plus the unsafe-route test by pasting this code:
import unittest
from ticket_router import run_safe_ticket, run_unsafe_ticket
class TicketRouterTests(unittest.TestCase):
def test_unsafe_urgent_ticket_auto_escalates(self):
result = run_unsafe_ticket("unsafe-1", "Payment account locked after breach")
self.assertEqual(result["priority"], "urgent")
self.assertEqual(result["status"], "auto_escalated")
What does this test prove?
- The TicketRouterTests class groups the router's regression tests.
- The run_unsafe_ticket() call sends an urgent ticket through the deliberately unsafe graph.
- The assertEqual() checks lock in the urgent classification plus the auto_escalated status.
- Save test_ticket_router.py.
- Run the first regression test from the integrated terminal with this command:
python -m unittest -v test_ticket_router
You should see one named test followed by OK. That first passing test now documents the exact unsafe behavior the approval gate was designed to stop.
First test not passing?
- Confirm that test_ticket_router.py sits beside ticket_router.py inside the langgraph-ticket-router folder.
- Check that the test class inherits from unittest.TestCase.
- Compare the asserted field names with priority plus status in the returned state.
Help me diagnose my unsafe-route unittest failure.
Cover the safe routes
The safe graph has three outcomes worth protecting. A routine ticket resolves directly. An urgent ticket reaches either escalation or rejection after the review decision.
- In test_ticket_router.py, place your cursor after the final assertion in test_unsafe_urgent_ticket_auto_escalates().
- Add the routine-route test by pasting this method inside TicketRouterTests:
def test_routine_ticket_resolves_without_approval(self):
result = run_safe_ticket("routine-1", "How do I change my profile photo?")
self.assertEqual(result["priority"], "routine")
self.assertEqual(result["status"], "resolved")
self.assertIsNone(result["approved"])
Why test the routine route?
- The routine ticket contains none of the terms that trigger urgent routing.
- The test requires a resolved status without an approval decision.
- The assertIsNone() check proves the approval node did not run.
- Save test_ticket_router.py.
- Run the expanded test suite with this command:
python -m unittest -v test_ticket_router
You should see two named tests followed by OK. The routine path now has a guardrail against accidental approval prompts.
Routine test failing?
- Inspect the failing assertion to identify whether priority, status, or approved differs.
- Confirm that the routine ticket text matches the method exactly.
- Check that run_safe_ticket() returns before the interrupt when the ticket is routine.
Help me trace my failing routine-route assertion.
- Place your cursor after the final assertion in test_routine_ticket_resolves_without_approval().
- Add the approved urgent-route test by pasting this method inside TicketRouterTests:
def test_approved_urgent_ticket_escalates(self):
result = run_safe_ticket("approved-1", "Security breach locked my account", True)
self.assertEqual(result["status"], "escalated")
self.assertTrue(result["approved"])
What does approval change?
- The urgent ticket reaches the interrupt because its text contains urgent terms.
- The True decision resumes the checkpointed thread on the approved route.
- The assertions require an escalated status plus a stored approval value.
- Save test_ticket_router.py.
- Run the expanded test suite with this command:
python -m unittest -v test_ticket_router
You should see three named tests followed by OK. The approved urgent path now has proof that resumption reaches escalation.
Approved route not escalating?
- Inspect the failing assertion to identify whether the returned status or approved value differs.
- Confirm that True is the third argument passed to run_safe_ticket().
- Check that the resumed graph reuses the same thread_id configuration.
Help me trace my approved urgent-route failure.
- Place your cursor after the final assertion in test_approved_urgent_ticket_escalates().
- Add the rejected urgent-route test plus the direct-run guard by pasting this code:
def test_rejected_urgent_ticket_stops(self):
result = run_safe_ticket("rejected-1", "Payment outage", False)
self.assertEqual(result["status"], "rejected")
self.assertFalse(result["approved"])
if __name__ == "__main__":
unittest.main()
What does rejection protect?
- The False decision resumes the urgent ticket on the rejection route.
- The assertions require a rejected status plus a stored rejection value.
- The final guard also lets the file start the test runner when it is executed directly.
- Save test_ticket_router.py.
✔️ Awesome, I've got everything!
Your test file now covers the unsafe route plus all three safe outcomes. Make sure test_ticket_router.py is saved.
ⓧ I'd like to double check the full code
import unittest
from ticket_router import run_safe_ticket, run_unsafe_ticket
class TicketRouterTests(unittest.TestCase):
def test_unsafe_urgent_ticket_auto_escalates(self):
result = run_unsafe_ticket("unsafe-1", "Payment account locked after breach")
self.assertEqual(result["priority"], "urgent")
self.assertEqual(result["status"], "auto_escalated")
def test_routine_ticket_resolves_without_approval(self):
result = run_safe_ticket("routine-1", "How do I change my profile photo?")
self.assertEqual(result["priority"], "routine")
self.assertEqual(result["status"], "resolved")
self.assertIsNone(result["approved"])
def test_approved_urgent_ticket_escalates(self):
result = run_safe_ticket("approved-1", "Security breach locked my account", True)
self.assertEqual(result["status"], "escalated")
self.assertTrue(result["approved"])
def test_rejected_urgent_ticket_stops(self):
result = run_safe_ticket("rejected-1", "Payment outage", False)
self.assertEqual(result["status"], "rejected")
self.assertFalse(result["approved"])
if __name__ == "__main__":
unittest.main()
Run the complete regression suite
The verbose test runner names each route as it executes. Before you run it, do you expect all four routing outcomes to agree with their asserted final states?
- Run the complete test module with this command:
python -m unittest -v test_ticket_router
You should see four named tests followed by OK. You now have automated proof that the approval gate preserves routine routing plus both human decisions.
A test reports a failure?
- Read the failed test name to identify the affected route.
- Inspect the assertion details to find the state field with an unexpected value.
- Correct the matching test or router code before running the complete command again.
Help me diagnose my failing ticket-router test suite.
Secret mission
Let the Reviewer Edit the Action
Give the approval gate a third option. An approving reviewer can replace the proposed action before the same graph thread resumes.
Clean Up Your Resources
Clean Up Your Resources
Five passing tests now protect your completed LangGraph router. Choose the cleanup option that fits your plans because this local project has no ongoing cost.
Resources you used:
- The local langgraph-ticket-router workspace open in Visual Studio Code.
- The selected .venv environment containing LangGraph 1.2.11.
- The requirements.txt dependency file.
- The ticket_router.py application file.
- The test_ticket_router.py test file.
Keep everything running
No action is needed. Choose this option if you plan to rerun the router or extend its approval flow.
- Keep the langgraph-ticket-router folder in its current location.
- Leave .venv inside the workspace so the pinned environment remains available.
- Return to the existing Visual Studio Code workspace when you want to continue.
Your source files remain available between sessions. Any InMemorySaver checkpoints still disappear when the process exits.
Pause - I'll come back to this later
Pausing keeps every project file while stopping your local work. No background service continues running.
- Close the integrated terminal from earlier.
- Close Visual Studio Code.
- Keep the langgraph-ticket-router folder for your next session.
Closing the process discards its in-memory checkpoints. Your application code remains unchanged.
Delete - I don't want to use this again
Deleting the folder is permanent. Choose this option only when you no longer need the code or isolated environment.
The deletion steps depend on your operating system.
macOS
- Right-click langgraph-ticket-router in the Explorer sidebar.
- Choose the context menu option that shows the folder in Finder.
- Close Visual Studio Code.
- Select langgraph-ticket-router in Finder.
- Press Cmd+Delete to move the folder to your Trash.
- Empty your Trash to remove the folder permanently.
The langgraph-ticket-router folder should no longer appear in its original location. Its .venv environment and three project files are gone with it.
Windows
- Right-click langgraph-ticket-router in the Explorer sidebar.
- Choose the context menu option that shows the folder in File Explorer.
- Close Visual Studio Code.
- Select langgraph-ticket-router in File Explorer.
- Press Shift+Delete to remove the folder permanently.
- Confirm the deletion if Windows asks.
The langgraph-ticket-router folder should no longer appear in its original location. Its .venv environment and three project files are gone with it.
Nice Work!
Nice Work!
You did it! Your terminal-based LangGraph router now classifies support tickets through a visible workflow with human oversight for urgent actions.
What you learned:
- Built a workflow with typed shared state. Its conditional edges send routine tickets to resolution. Urgent tickets follow a separate review route.
- Exposed the risk of automatic escalation by running the unsafe route. Added a checkpoint-backed interrupt that pauses urgent tickets for a human decision. Resumed each workflow with the same thread ID.
- Created automated regression tests with unittest. Five passing tests now prove the router's core routes.
- Secret Mission: Extended the approval decision with reviewer-edited actions. The completed urgent ticket now retains the reviewer's replacement action.
Ready to quiz yourself?