Lab 07 · Ticket triage pipeline
Build a LangGraph workflow that classifies a ticket in parallel, routes it to one of three desks, and drafts a reply in a grade-and-revise loop.
What you will build
A support-ticket pipeline written as a single LangGraph StateGraph. Three classifiers (category, urgency, sentiment) run in parallel, a triage node routes the ticket to the billing, technical or account desk, and a draft and grade loop rewrites the reply until a grader approves it or a revision budget runs out.
What you will learn
- How a
StateGraphpasses one shared state between nodes - How nodes return partial updates, and how a reducer merges list updates
- How fan-out and fan-in edges run nodes in parallel
- How
Commandupdates state and routes in one step - How a conditional edge builds a loop with a hard stop
- How
context=passes run settings that never enter the state
Check your prerequisites
You need three things on your machine and three values from your instructor.
- Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
- A code editor. VS Code with the Python extension works well.
- A terminal. PowerShell on Windows, Terminal on macOS or Linux.
python --versionpython3 --versionFrom your instructor you get an Azure OpenAI endpoint, an API key and a deployment name. Keep the key private: never paste it into code or share it in chat.
Create the project folder and virtual environment
Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.
mkdir C:\labs\lab-07-triage-pipeline
cd C:\labs\lab-07-triage-pipeline
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-07-triage-pipeline
cd ~/labs/lab-07-triage-pipeline
python3 -m venv .venv
source .venv/bin/activateKeep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.
When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.
PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.
This is what your folder will look like at the end of the lab:
Install the libraries
Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
langgraph==1.2.11langgraph is the graph runtime underneath create_agent. In this lab you use it directly.
Then install everything into your virtual environment. It takes a minute or two.
python -m pip install --upgrade pip
pip install -r requirements.txtpython -m pip install --upgrade pip
pip install -r requirements.txtAdd your Azure OpenAI settings
Create a file named .env (the name starts with a dot) in your lab folder. Paste the template and replace the three placeholders with the values from your instructor.
# Azure OpenAI settings. Your instructor gives you these three values.
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=<paste-your-key-here>
AZURE_OPENAI_DEPLOYMENT=<your-chat-deployment-name>
# Only used when your endpoint does NOT end in /openai/v1.
OPENAI_API_VERSION=2025-04-01-preview- AZURE_OPENAI_ENDPOINT is your resource URL. If it ends in
/openai/v1, keep it that way. If it is justhttps://<resource>.openai.azure.com/, paste that; the helper in the next step handles both. - AZURE_OPENAI_API_KEY is your secret key.
- AZURE_OPENAI_DEPLOYMENT is the deployment name, for example
gpt-5-mini. On Azure you call a deployment, not a model name.
Never commit .env to Git or share it. If you use Git, add a .gitignore file containing .env and .venv/.
Create the Azure OpenAI helper
Every lab talks to Azure OpenAI through one small helper file. Create model_setup.py and add the blocks below one after another. You write it once per lab folder; the lab code then only needs from model_setup import get_model.
Block 1Imports, .env loading and a console fix
load_dotenv() reads your .env file and puts every line into environment variables, so your key never appears in code.
The loop below it switches the console to UTF-8. Windows terminals default to an older encoding and crash when a model prints curly quotes or dashes.
"""Azure OpenAI model setup shared by every lab.
Import it in lab code as: from model_setup import get_model, get_embeddings
"""
import asyncio
import os
import sys
from dotenv import load_dotenv
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel
# Read AZURE_OPENAI_* values from the .env file next to your lab code.
load_dotenv()
# Windows consoles default to cp1252 and crash on the curly quotes models love to print.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors="replace")Block 2Read settings safely
_setting() fetches a value from the environment and stops with a friendly message if it is missing, instead of a long stack trace.
_uses_v1_api() checks the shape of your endpoint. Azure offers two:
https://<resource>.openai.azure.com/openai/v1is the newer OpenAI-compatible API.https://<resource>.openai.azure.com/is the classic API that needs anapi_version.
def _setting(name: str) -> str:
"""Return a required setting from .env, or stop with a readable message."""
value = os.getenv(name, "").strip()
if not value:
sys.exit(f"Missing {name} in your .env file. Open .env and fill it in.")
return value
def _uses_v1_api(endpoint: str) -> bool:
"""True for the OpenAI-compatible Azure endpoint that ends in /openai/v1."""
return endpoint.rstrip("/").endswith("/openai/v1")Block 3Build the chat model
get_model() returns a LangChain chat model connected to your deployment.
- With a
/openai/v1endpoint it usesChatOpenAIwithbase_url. On Azure the deployment name goes inmodel. - With a classic endpoint it uses
AzureChatOpenAIwithazure_deploymentandapi_version.
Either way the lab code just calls get_model() and never cares which one it got.
def get_model(deployment: str | None = None, **kwargs) -> BaseChatModel:
"""Return a chat model for your Azure OpenAI deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import ChatOpenAI
return ChatOpenAI(model=name, base_url=endpoint, api_key=api_key, **kwargs)
from langchain_openai import AzureChatOpenAI
return AzureChatOpenAI(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
**kwargs,
)Block 4Build the embeddings model
get_embeddings() does the same for embeddings, which turn text into vectors for search. Only the retrieval labs use it, but keeping one helper for every lab is simpler.
def get_embeddings(deployment: str | None = None) -> Embeddings:
"""Return an embeddings model for your Azure OpenAI embeddings deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import OpenAIEmbeddings
return OpenAIEmbeddings(model=name, base_url=endpoint, api_key=api_key)
from langchain_openai import AzureOpenAIEmbeddings
return AzureOpenAIEmbeddings(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
)Block 5A Windows fix for async code
use_stable_event_loop() matters only for the async MCP lab. The default Windows event loop can crash on exit after async network sessions close. On macOS and Linux it does nothing.
def use_stable_event_loop() -> None:
"""On Windows, use the Selector event loop so async MCP code exits cleanly."""
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())Check that the helper can reach your deployment with a one-line test:
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"You should see a short greeting. If you see an error instead, check the troubleshooting step at the end.
Write the triage pipeline
In earlier labs create_agent decided what happens next. Here you draw the flow as a graph, which makes it predictable and testable:
- A node is a Python function. It reads the state and returns a dict with only the keys it changes.
- An edge says which node runs next. Nodes reachable in the same step run in parallel.
This is the graph you are building:
STARTfans out toclassify_category,classify_urgencyandclassify_sentiment.- All three fan in to
triage, which jumps tobilling,technicaloraccount. - Each desk goes to
draft_reply, thengrade_reply, which either loops back todraft_replyor ends.
Only three nodes call the model: classify_sentiment, draft_reply and grade_reply. Everything else is plain Python.
Create an empty file named triage_pipeline.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
StateGraph,STARTandENDbuild the graph.Commandlets a node update state and choose the next node.Runtimegives a node access to the per-runcontext.operatorprovidesoperator.add, used as a reducer below.
"""Lab 07: the Northwind ticket triage pipeline as one LangGraph StateGraph.
Run: python triage_pipeline.py
"""
import operator
from dataclasses import dataclass
from typing import Annotated, Any, Callable, Literal
from langchain_core.language_models import BaseChatModel
from langgraph.graph import END, START, StateGraph
from langgraph.graph.state import CompiledStateGraph
from langgraph.runtime import Runtime
from langgraph.types import Command
from typing_extensions import TypedDict
from model_setup import get_modelBlock 2Business rules as data
Keywords for the category and urgency classifiers, and one playbook per desk. The playbook is the instruction the draft must follow. Keeping rules in dictionaries makes them easy to read and change without touching the graph.
CATEGORY_KEYWORDS = {
"billing": ("invoice", "charge", "refund", "payment"),
"technical": ("error", "crash", "timeout", "500", "bug"),
"account": ("login", "password", "sso", "seat", "access"),
}
URGENT_WORDS = ("outage", "urgent", "asap", "block", "production")
PLAYBOOKS = {
"billing": "Confirm the invoice number and say whether a refund was issued.",
"technical": "Acknowledge the error, name the component, give the next step.",
"account": "Verify identity first, then explain the access change.",
}Block 3The state and the run context
TriageState is the shared memory of one run. total=False means every key is optional, because nodes fill them in over time.
- Each classifier writes its own key. Parallel nodes that write the same key would fail with
InvalidUpdateError. feedbackhas a reducer:Annotated[list[str], operator.add]. Without it, each new value replaces the old one. With it, LangGraph appends, so the grader's instructions accumulate across rounds.
TriageContext holds settings for the run (tenant, agent name, revision budget). You pass it with context=; it is read-only and never stored in state.
class TriageState(TypedDict, total=False):
"""Everything the pipeline knows about one ticket."""
ticket: str
category: str # one key per classifier, so the parallel writes never collide
urgency: str
sentiment: str
handler: str
playbook: str
draft: str
attempts: int
approved: bool
feedback: Annotated[list[str], operator.add] # one instruction per grading round
@dataclass
class TriageContext:
"""Per-run configuration: passed as `context=`, never stored in state."""
tenant: str = "northwind"
agent_name: str = "Ada"
max_revisions: int = 2Block 4Two keyword classifiers
These nodes are plain Python: free, instant and deterministic. Each returns a partial update, for example {"urgency": "high"}. LangGraph merges it into the state for you.
def classify_category(state: TriageState) -> dict[str, Any]:
"""Keyword scoring: deterministic, free, and good enough for a first pass."""
text = state["ticket"].lower()
hits = {name: sum(w in text for w in words) for name, words in CATEGORY_KEYWORDS.items()}
best = max(hits, key=lambda name: hits[name])
return {"category": best if hits[best] else "technical"}
def classify_urgency(state: TriageState) -> dict[str, Any]:
"""Count urgent words: two or more is high, one is normal, none is low."""
hits = sum(word in state["ticket"].lower() for word in URGENT_WORDS)
return {"urgency": "high" if hits >= 2 else "normal" if hits else "low"}Block 5The router: pick_handler and triage
pick_handler is the routing policy in a plain function, so it is easy to test. A high-urgency, negative ticket goes to the account desk (churn risk), whatever it is about.
triage returns a Command that writes handler and jumps to that desk in one step. The return type Command[Literal["billing", "technical", "account"]] is not decoration: LangGraph reads it to know the possible destinations and to draw them in the diagram.
def pick_handler(state: TriageState) -> str:
"""Churn risk goes to the account desk whatever the ticket is about."""
if state["urgency"] == "high" and state["sentiment"] == "negative":
return "account"
return state["category"] if state["category"] in PLAYBOOKS else "technical"
def triage(state: TriageState) -> Command[Literal["billing", "technical", "account"]]:
"""Write the decision and jump to it in one atomic step."""
handler = pick_handler(state)
return Command(update={"handler": handler}, goto=handler)Block 6The loop exit
should_revise is a conditional edge: it returns the name of the next node. It ends the run when the grader approved, or when attempts passed max_revisions from the context. The second exit is the safety net: a loop that depends only on a model saying yes could run forever.
With max_revisions=2 a ticket gets at most three drafts.
def should_revise(
state: TriageState, runtime: Runtime[TriageContext]
) -> Literal["draft_reply", "__end__"]:
"""Two exits: the grader approved, or the revision budget is spent."""
if state["approved"] or state["attempts"] > runtime.context.max_revisions:
return END
return "draft_reply"Block 7The three model nodes
make_model_nodes builds the nodes that call the model, so they can share one model object.
classify_sentimentsearches the answer for one of three words and falls back toneutral. Models do not reliably answer with exactly one word.draft_replyreads the playbook, the ticket and all feedback so far, and incrementsattempts. It takesruntimeto read the agent name and tenant.grade_replyapproves, or returnsREVISE: <instruction>. The instruction is appended tofeedbackthanks to the reducer.
def make_model_nodes(model: BaseChatModel) -> tuple[Callable[..., dict[str, Any]], ...]:
"""Return the three nodes that call the model: sentiment, draft and grade."""
def classify_sentiment(state: TriageState) -> dict[str, Any]:
"""Ask the model for the tone and normalise it to one of three words."""
question = "Classify the customer's tone. One word: positive, neutral or negative."
answer = model.invoke(f"{question}\n\n{state['ticket']}").text.lower()
tones = ("negative", "positive", "neutral")
return {"sentiment": next((tone for tone in tones if tone in answer), "neutral")}
def draft_reply(state: TriageState, runtime: Runtime[TriageContext]) -> dict[str, Any]:
"""Write (or rewrite) the reply, following the playbook and any feedback."""
context = runtime.context
prompt = (
f"You are {context.agent_name}, a support agent at {context.tenant}. "
f"Write a short reply.\nPlaybook: {state['playbook']}\nTicket: {state['ticket']}\n"
f"Reviewer feedback to address: {'; '.join(state['feedback'])}"
)
return {"draft": model.invoke(prompt).text.strip(), "attempts": state["attempts"] + 1}
def grade_reply(state: TriageState) -> dict[str, Any]:
"""Approve the draft, or send it back with one concrete instruction."""
prompt = (
'Reply "APPROVE" if the draft follows the playbook and answers the ticket, '
'otherwise "REVISE: <one concrete instruction>".\n'
f"Playbook: {state['playbook']}\nDraft: {state['draft']}"
)
verdict = model.invoke(prompt).text.strip()
if verdict.upper().startswith("APPROVE"):
return {"approved": True}
return {"approved": False, "feedback": [verdict.partition(":")[2].strip()]}
return classify_sentiment, draft_reply, grade_replyBlock 8Wire the graph
- The loop adds an edge from
STARTto each classifier (fan-out) and from each classifier totriage(fan-in).triagewaits for all three. - Each desk is a tiny node that sets the playbook, then goes to
draft_reply.text=playbookin the lambda captures the current value; without it every desk would get the last playbook. add_conditional_edgescloses the loop. The list["draft_reply", END]declares the possible targets for validation and the diagram.
There is no edge out of triage: its Command does the routing.
def build_graph(model: BaseChatModel | None = None) -> CompiledStateGraph:
"""Compile the pipeline. Pass a model to override the Azure OpenAI default."""
classify_sentiment, draft_reply, grade_reply = make_model_nodes(model or get_model())
builder = StateGraph(TriageState, context_schema=TriageContext)
builder.add_node("classify_category", classify_category)
builder.add_node("classify_urgency", classify_urgency)
builder.add_node("classify_sentiment", classify_sentiment)
builder.add_node("triage", triage)
builder.add_node("draft_reply", draft_reply)
builder.add_node("grade_reply", grade_reply)
for classifier in ("classify_category", "classify_urgency", "classify_sentiment"):
builder.add_edge(START, classifier) # fan-out: one super-step, three nodes
builder.add_edge(classifier, "triage") # fan-in: triage waits for all three
for desk, playbook in PLAYBOOKS.items():
# each desk is one node whose whole job is to choose the playbook the draft follows
builder.add_node(desk, lambda state, text=playbook: {"playbook": text})
builder.add_edge(desk, "draft_reply")
builder.add_edge("draft_reply", "grade_reply")
builder.add_conditional_edges("grade_reply", should_revise, ["draft_reply", END])
return builder.compile(name="northwind-triage")Block 9Run one ticket and watch every step
The demo prints the graph as a Mermaid diagram, then streams the run.
stream_mode=["updates", "values"]yields two kinds of events:updates(what each node returned) andvalues(the full state after each step). The lastvaluesevent is the final state.- The initial state sets
attempts: 0andfeedback: [], because the nodes read them before anything writes them.
The sample ticket is about an invoice, but it is urgent and angry, so watch where it is routed.
SAMPLE_TICKET = (
"We were charged twice for invoice INV-2291 this morning and the refund button returns a "
"500 error. This is blocking our month-end close. Please fix it ASAP."
)
def short(value: Any, width: int = 70) -> str:
"""One-line preview of a state value."""
text = str(value).replace("\n", " ")
return text if len(text) <= width else text[: width - 3] + "..."
if __name__ == "__main__":
graph = build_graph()
print(graph.get_graph().draw_mermaid())
inputs: TriageState = {"ticket": SAMPLE_TICKET, "attempts": 0, "feedback": []}
result: dict[str, Any] = {}
for mode, chunk in graph.stream(inputs, context=TriageContext(), stream_mode=["updates", "values"]):
if mode == "values":
result = chunk # the full state after each step; the last one is the final state
continue
for node, update in chunk.items():
fields = ", ".join(f"{key}={short(value, 50)}" for key, value in (update or {}).items())
print(f"[{node}] {fields}")
print(f"\n{result['handler']} desk · {result['attempts']} draft(s) · approved={result.get('approved')}")
print(result["draft"])Run the pipeline
python triage_pipeline.pypython3 triage_pipeline.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
---
config:
flowchart:
curve: linear
---
graph TD;
__start__([<p>__start__</p>]):::first
classify_category(classify_category)
classify_urgency(classify_urgency)
classify_sentiment(classify_sentiment)
triage(triage)
draft_reply(draft_reply)
grade_reply(grade_reply)
billing(billing)
technical(technical)
account(account)
__end__([<p>__end__</p>]):::last
__start__ --> classify_category;
__start__ --> classify_sentiment;
__start__ --> classify_urgency;
account --> draft_reply;
billing --> draft_reply;
classify_category --> triage;
classify_sentiment --> triage;
classify_urgency --> triage;
draft_reply --> grade_reply;
grade_reply -.-> __end__;
grade_reply -.-> draft_reply;
technical --> draft_reply;
triage -.-> account;
triage -.-> billing;
triage -.-> technical;
classDef default fill:#f2f0ff,line-height:1.2
classDef first fill-opacity:0
classDef last fill:#bfb6fc
[classify_category] category=billing
[classify_urgency] urgency=high
[classify_sentiment] sentiment=negative
[triage] handler=account
[account] playbook=Verify identity first, then explain the access ...
[draft_reply] draft=Hi — I’m Ada. I can take care of this right awa..., attempts=1
[grade_reply] approved=True
account desk · 1 draft(s) · approved=True
Hi — I’m Ada. I can take care of this right away, but first I need to verify you.
Please confirm the account email or company name on the Northwind account and the last 4 digits of the payment method used for INV-2291 (and the approximate time the two charges hit). Once you reply, I will:
- Temporarily enable a backend refund override for your account so I can issue the refund immediately (this won’t change your normal permissions and will be revoked after the issue is resolved).
- Manually process the duplicate refund and escalate the 500 error to engineering so the refund button is fixed.
I’ll aim to issue the refund within 2 hours after verification and will keep you updated.Read the output from top to bottom:
- The Mermaid diagram comes first. Paste it into mermaid.live and check that
triagehas three dotted edges, one per desk. - The three classifiers print one after another, but they ran in the same step.
triageonly ran after all three finished. - The ticket mentions an invoice and a refund, so
categoryisbilling. Buturgencyishighandsentimentisnegative, sotriageroutes it to the account desk. draft_replyandgrade_replymay repeat. EachREVISEadds one item tofeedback, and the run stops at approval or after three drafts. The number of rounds varies from run to run.
Check your work
You are done when
- The Mermaid diagram shows
triagewith dotted edges tobilling,technicalandaccount. category,urgencyandsentimentall appear before[triage].- The sample ticket reaches the account desk although its category is
billing. - The summary line shows between 1 and 3 drafts.
Troubleshooting
InvalidUpdateError: At key '...': Can receive only one value per step
Two parallel nodes wrote the same key. Each classifier must return only its own key (category, urgency or sentiment).
KeyError: 'attempts' or 'feedback'
The initial state must include "attempts": 0 and "feedback": [].
The diagram shows no edges out of triage
The Command[Literal[...]] return annotation on triage is missing, so LangGraph cannot see its destinations.
AttributeError: 'NoneType' object has no attribute 'max_revisions'
Pass context=TriageContext() (or context={}) when you call stream or invoke.
401 Unauthorized or 'Access denied due to invalid subscription key'
The key in .env is wrong or has spaces around it. Copy it again from your instructor, with no quotes.
404 'DeploymentNotFound' or 'The API deployment for this resource does not exist'
AZURE_OPENAI_DEPLOYMENT must be the deployment name exactly as your instructor gave it, and the endpoint must be the same resource.
429 Too Many Requests
The shared deployment is busy. Wait 30 seconds and run again.
ModuleNotFoundError: No module named 'model_setup' (or another package)
Run the command from inside your lab folder, with the virtual environment activated. Reinstall with pip install -r requirements.txt if a library is missing.
'python' is not recognized, or the wrong Python runs
Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.
Go further
- Write a friendly ticket such as Thanks, just a question about my invoice and check it reaches the billing desk.
- Run with
context=TriageContext(max_revisions=0)and confirm the loop stops after one draft. - Give
feedbacka reducer that keeps only the last three instructions:lambda left, right: (left + right)[-3:]. - Add a
CachePolicy(ttl=300)toclassify_sentimentand compile withcache=InMemoryCache(). Re-running the same ticket should skip that model call. - Rewrite the pipeline with the functional API (
@entrypointand@task) and compare the two versions.
Stuck?
Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.