July 12, 2026
Building a Multi-Agent Support Ticket Triage With LangGraph
Route tickets and draft replies locally with Ollama or via API.

By Sarah Lea
14 min read
During my time as an IT consultant at an SME, one of the most tedious problems I constantly faced was the morning support inbox. Someone always had to manually read and sort every single ticket, sifting through endless password resets and billing questions to find the few truly urgent issues. It was purely mechanical work.
In a previous article, I showed how to build a single data agent, the CSV Plot Agent. While perfect for beginners, single agents only get you so far. Real-world workflows need a team of specialized agents handing tasks off to each other, with the ability to route work back to a human when needed.
That's why in this article, we are building a multi-agent triage system with LangGraph. One agent classifies the ticket, a router decides the next step, and another drafts a response. Any urgent cases are automatically escalated to a human. Best of all, we will design it to run seamlessly both locally with Ollama and via a cloud API using the exact same graph. (You can find the complete code in the GitHub repo linked below).
Let's dive in.
Table of Content 1 — From one agent to many: Why multi-agent systems? 2 — How LangGraph works: State, Nodes, and the Graph 3 — Our Tech Stack: LangGraph, Ollama & an API Model 4 — Step-by-Step Guide Multi-Agent Final Thoughts Where Can You Continue Learning?
1 — From one agent to many: Why multi-agent systems?
A single agent is like a solo worker who never collaborates. In a real company, that approach quickly reaches its limits. A multi-agent system works like a team of specialists where everyone has a clearly defined role and they coordinate their next steps.
Support ticket triage is the perfect example because the workflow naturally branches out:
- Routine billing questions can be answered automatically.
- Vague feature requests get a drafted reply for a human to refine later.
- Urgent messages like "My application is down" bypass automation entirely and go straight to a human.
We use LangGraph for exactly these branches (deciding what happens next based on the current state). Our small agent team consists of three members:
- Classifier Agent: Reads the ticket and decides on category and priority.
- Responder Agent: Drafts a response for tickets that we can safely process automatically.
- Human Gate: A node that escalates urgent tickets to a person instead of answering them.
And in between sits the routing logic, our supervisor. It looks at the priority and decides which path the ticket takes.
2 — How LangGraph works: State, Nodes, and the Graph
To better understand how LangGraph works, let's imagine a flowchart on a whiteboard:
- State: This is the clipboard that is passed from station to station. It contains everything we know about the ticket so far: the original text, the category, the priority, possibly the drafted response, and the status.
- Node: It's a station: A function that receives the state, performs a task, and returns its updates to the state.
- Edge: A fixed arrow from one station to the next.
- Conditional Edge: A small router function looks at the state and decides where to go, instead of always going to the same next station.
So the entire system is: The state flows through Nodes, Edges connect them, and Conditional Edges allow the path to branch depending on what the state is currently saying.
Note for Newbies: The state is actually just a Python dictionary with a defined shape. We describe this shape with a TypedDict and thereby state that this dictionary should have these specific keys.
3 — Our Tech Stack: LangGraph, Ollama & an API Model
Before we jump into the code, let's take a quick look at the tools for this project:
LangGraph The framework is our orchestration layer. We use it to define the nodes, the state, and the routing between the agents.
Ollama with Llama3 This allows us to run everything locally, for free, and on our own machine. Since support tickets often contain personal customer data, in this example, it is a real advantage in a business environment to keep the data on your own infrastructure instead of sending it to an external API.
API Model (here gpt-4o-mini) If you don't have any local hardware to spare or want more performance than a small, local model provides, this is the alternative. Since we write the graph once, it is also possible to start locally first and switch to an API later without rewriting the logic.
4 — Step-by-Step Guide Multi-Agent
→ 🤓 You can find the complete code in the GitHub repo 🤓 ←
1) Mise en place
First, we install the necessary Python packages: We use the langgraph framework to build the graph (nodes, state & routing between the agents), we install langchain-ollama as a bridge to Ollama, and we install langchain-openai as a bridge to our API model.
For that, we open a terminal and use the following code:
# Install LangGraph and the two model integrations
pip install langgraph langchain-ollama langchain-openai# Install LangGraph and the two model integrations
pip install langgraph langchain-ollama langchain-openaiTip for Newbies: It's best to create a fresh virtual environment first, so that the packages for this project do not collide with your other projects. On Windows, you can do this with python -m venv yournameand then yourname\Scripts\activate. On Mac or Linux, it's python3 -m venv venv and then source venv/bin/activate.
If you haven't installed Ollama yet, download it from the website: https://ollama.com/download
Next, we download the local model. In our case, Llama3. This step does happen in the terminal as well:
# Download the Llama 3 model so we can run it locally
ollama pull llama3# Download the Llama 3 model so we can run it locally
ollama pull llama32) Building the agents: Classifier, Responder, and the human gate
Next, we build the agents. From here on, we create a single file called triage.py in our project in Visual Studio Code and add the code from the following steps into this one file, from top to bottom.
Building the Factory Function:
First, we begin with the part that makes both backends possible. For this, we write a small factory function get_llm that hands us the correct model depending on a single setting.
The sole task of a factory function is to return a finished object. As an analogy, we can imagine pressing a button on a coffee machine labeled "local" or "API" and being handed the appropriate cup, without needing to know what happens inside. That is exactly what get_llm does.
For this, we define the function with a backend parameter. Here, "local" is the default value: If someone calls the function without specifying anything, the local model is automatically used.
# A factory that returns the model we want, so the rest of the code
# never has to care which backend we are using.
def get_llm(backend: str = "local"):
if backend == "local":
from langchain_ollama import ChatOllama
# temperature=0 keeps classification stable and repeatable
return ChatOllama(model="llama3", temperature=0)
elif backend == "api":
from langchain_openai import ChatOpenAI
return ChatOpenAI(model="gpt-4o-mini", temperature=0)
else:
raise ValueError("backend must be 'local' or 'api'")
# Choose your backend here: "local" or "api"
BACKEND = "local"
llm = get_llm(BACKEND)# A factory that returns the model we want, so the rest of the code
# never has to care which backend we are using.
def get_llm(backend: str = "local"):
if backend == "local":
from langchain_ollama import ChatOllama
# temperature=0 keeps classification stable and repeatable
return ChatOllama(model="llama3", temperature=0)
elif backend == "api":
from langchain_openai import ChatOpenAI
return ChatOpenAI(model="gpt-4o-mini", temperature=0)
else:
raise ValueError("backend must be 'local' or 'api'")
# Choose your backend here: "local" or "api"
BACKEND = "local"
llm = get_llm(BACKEND)Through the if-elif-else statement, the function checks what is in backend and, depending on that, returns llama3, gpt-4o-mini, or an error message.
We place the imports here (instead of at the very top) because someone who works exclusively locally doesn't even need to import langchain-openai, and vice versa.
With temperature=0, we control how creatively or randomly the model responds. A high value introduces variety, while a value of 0 makes the answers as predictable and stable as possible. When classifying tickets, this is exactly what we want, so that the same ticket reliably gets the same category.
With BACKEND = "local", we define the value and call the function in the next line.
Building the First Agent: The classifier reads the ticket and returns a category as well as a priority. We ask the model to respond in JSON so that we can work with the result programmatically:
With the parse_json function, we transform the text we receive back into a real Python dictionary. Using the find functions, we first ensure that the function locates the JSON brackets, because many local models do not always return clean JSON and often write something around it like "Sure, here is the JSON: {...} I hope this helps." Afterward, our data is in a format that Python understands.
With the classify_ticket function, we define our classifier agent. It receives the current state and returns its updates to it. Next, we build our instructions for the model and assign it a clear role as a "Support triage assistant". We also strictly limit the response by providing 4 categories and 3 priorities. Additionally, we define that the response should be delivered as pure JSON, without any additional context. We intentionally define the prompt more strictly here so that the response comes back cleaner, thus making the work for the parse_json function easier.
With llm.invoke(prompt), we send the prompt to the model and receive the response back as an object.
Finally, we only return the two fields that this agent actually populates: category and priority. This is an important LangGraph characteristic: A node doesn't return the entire state, but only the parts it wants to change. LangGraph then automatically merges these updates into the shared state.
import json
def parse_json(text: str) -> dict:
# Local models sometimes wrap their JSON in extra text,
# so we grab everything between the first { and the last }.
start = text.find("{")
end = text.rfind("}") + 1
return json.loads(text[start:end])
def classify_ticket(state: dict) -> dict:
prompt = f"""You are a support triage assistant.
Classify the following support ticket.
Respond with ONLY a JSON object with two keys:
"category": one of ["billing", "technical", "account", "general"]
"priority": one of ["low", "medium", "high"]
Ticket:
{state['ticket']}"""
response = llm.invoke(prompt)
result = parse_json(response.content)
# We return only the keys we want to update in the shared state
return {"category": result["category"], "priority": result["priority"]}import json
def parse_json(text: str) -> dict:
# Local models sometimes wrap their JSON in extra text,
# so we grab everything between the first { and the last }.
start = text.find("{")
end = text.rfind("}") + 1
return json.loads(text[start:end])
def classify_ticket(state: dict) -> dict:
prompt = f"""You are a support triage assistant.
Classify the following support ticket.
Respond with ONLY a JSON object with two keys:
"category": one of ["billing", "technical", "account", "general"]
"priority": one of ["low", "medium", "high"]
Ticket:
{state['ticket']}"""
response = llm.invoke(prompt)
result = parse_json(response.content)
# We return only the keys we want to update in the shared state
return {"category": result["category"], "priority": result["priority"]}Now follows the second agent: The responder drafts a reply for tickets that we can safely automate:
With the draft_reply function, we define our responder agent. It too receives the current state and returns its updates to it. Next, we again build our instructions for the model and assign it a clear role as a "friendly & concise support agent". Additionally, we provide the model with an important guardrail: it shouldn't make any promises it cannot keep.
With llm.invoke(prompt), we send the prompt to the model and receive the response back as an object. Finally, we return two fields: The drafted response in draft_reply and the status "auto-drafted". This way, we can see later that this ticket was processed automatically and not escalated to a human.
def draft_reply(state: dict) -> dict:
prompt = f"""You are a friendly and concise support agent.
Write a short, helpful reply to this {state['category']} ticket.
Do not make promises you cannot keep.
Ticket:
{state['ticket']}"""
response = llm.invoke(prompt)
return {"draft_reply": response.content, "status": "auto-drafted"}def draft_reply(state: dict) -> dict:
prompt = f"""You are a friendly and concise support agent.
Write a short, helpful reply to this {state['category']} ticket.
Do not make promises you cannot keep.
Ticket:
{state['ticket']}"""
response = llm.invoke(prompt)
return {"draft_reply": response.content, "status": "auto-drafted"}Finally, we build the third agent:
The human gate is a node that doesn't call the model at all. Its entire task is to mark the ticket as escalated so that a person processes it instead of an automatic response. Therefore, we only return two fields here: an empty draft_reply, because we intentionally do not want an automatic response to be generated, and the status "escalated to human review". This way, it is clearly stored in the state that a person needs to take care of this ticket.
def escalate_to_human(state: dict) -> dict:
# No auto-reply for urgent tickets. We flag them for a person.
return {
"draft_reply": "",
"status": "escalated to human review",
}def escalate_to_human(state: dict) -> dict:
# No auto-reply for urgent tickets. We flag them for a person.
return {
"draft_reply": "",
"status": "escalated to human review",
}3) Wiring the graph: State, Nodes, and conditional transitions
We have built the three agents. And each agent has a clear task. Next, we need to connect them.
To do this, we first describe the shape of our shared state:
With the TicketState class, we define the shape of our shared state. This is exactly the clipboard from Section 2 that is passed from node to node. Here, we define which fields are allowed on it and what type they have.
At the same time, these 5 fields also show the path of a ticket through the system. ticket contains the original ticket text that we start with. category & priority are populated by the classifier, draft_reply by the responder (or left empty in case of an escalation), and status records at the end where the ticket ended up.
from typing import TypedDict
class TicketState(TypedDict):
ticket: str # the original ticket text
category: str # filled in by the classifier
priority: str # filled in by the classifier
draft_reply: str # filled in by the responder (or cleared on escalation)
status: str # where the ticket ended upfrom typing import TypedDict
class TicketState(TypedDict):
ticket: str # the original ticket text
category: str # filled in by the classifier
priority: str # filled in by the classifier
draft_reply: str # filled in by the responder (or cleared on escalation)
status: str # where the ticket ended upNext, we define the router. This "supervisor" reads the priority assigned by the classifier and decides which node comes next. It returns a string, and this string tells LangGraph which path to take:
With the route_by_priority function, we define our router. The function receives the current state and looks at the priority that was previously assigned by the classifier. Based on that, it decides which node comes next: If the priority is "high", the ticket goes to our human gate. In all other cases, we return "responder" and the ticket is answered automatically.
def route_by_priority(state: TicketState) -> str:
# Urgent tickets skip the automatic reply and go to a human.
if state["priority"] == "high":
return "human_review"
return "responder"def route_by_priority(state: TicketState) -> str:
# Urgent tickets skip the automatic reply and go to a human.
if state["priority"] == "high":
return "human_review"
return "responder"Now we assemble the graph itself. First, we create an empty graph using StateGraph(TicketState) and pass it the shape of our state that we just defined. This way, the graph knows which fields are on the clipboard that it will pass around later.
Next, we register our three agents as nodes. With add_node, we give each one a name and link it to its function: "classifier" to classify_ticket, "responder" to draft_reply, and "human_review" to escalate_to_human. These names are important because we will use them to reference the nodes from here on out.
After that, we connect the nodes with edges (meaning arrows). With add_edge(START, "classifier"), we determine that every ticket begins at the classifier. START is a fixed entry point provided by LangGraph.
Now comes the most exciting part: the conditional edge. With add_conditional_edges, we plug in our router. We pass three things: the node from which the branching happens ("classifier"), the router function that makes the decision (route_by_priority), and a lookup table. This table contains exactly the texts that our router can return: If it returns "responder", it goes to the responder node; if it returns "human_review", it goes to the human gate.
from langgraph.graph import StateGraph, START, END
# Create the graph and tell it the shape of our state
graph = StateGraph(TicketState)
# Register each agent as a node
graph.add_node("classifier", classify_ticket)
graph.add_node("responder", draft_reply)
graph.add_node("human_review", escalate_to_human)
# Every ticket starts at the classifier
graph.add_edge(START, "classifier")
# After classifying, the router decides where to go next
graph.add_conditional_edges(
"classifier",
route_by_priority,
{
"responder": "responder", # normal tickets get an auto-draft
"human_review": "human_review", # urgent tickets get escalated
},
)
# Both paths finish the run
graph.add_edge("responder", END)
graph.add_edge("human_review", END)
# Compile turns our definition into something runnable
app = graph.compile()from langgraph.graph import StateGraph, START, END
# Create the graph and tell it the shape of our state
graph = StateGraph(TicketState)
# Register each agent as a node
graph.add_node("classifier", classify_ticket)
graph.add_node("responder", draft_reply)
graph.add_node("human_review", escalate_to_human)
# Every ticket starts at the classifier
graph.add_edge(START, "classifier")
# After classifying, the router decides where to go next
graph.add_conditional_edges(
"classifier",
route_by_priority,
{
"responder": "responder", # normal tickets get an auto-draft
"human_review": "human_review", # urgent tickets get escalated
},
)
# Both paths finish the run
graph.add_edge("responder", END)
graph.add_edge("human_review", END)
# Compile turns our definition into something runnable
app = graph.compile()Hint for Newbies: The dictionary in add_conditional_edges is a lookup table. The keys are the strings that our route_by_priority function can return. The values are the nodes that these strings point to. When the router returns "human_review", LangGraph knows that it has to jump to the human_review node. This mapping is the heart of how a graph branches.
Finally, we connect both paths to the end. Regardless of whether a ticket was answered automatically or escalated, the run is complete after that, which is why both "responder" and "human_review" lead to END via an edge.
At the very end is graph.compile(). With this, we transform our definition (all the nodes and edges) into something actually executable. We save the result in app, and this exact app is what we call in the next section to send a ticket through the graph.
4) Execution: Once locally and once via API — The same graph, two backends
Now that we have created all the elements, we send a ticket through it:
# A clearly urgent billing issue
urgent_ticket = (
"I was charged twice for my subscription this month "
"and I need a refund today. This is unacceptable."
)
result = app.invoke({"ticket": urgent_ticket})
print("Category:", result["category"])
print("Priority:", result["priority"])
print("Status: ", result["status"])
print("Reply: ", result["draft_reply"])# A clearly urgent billing issue
urgent_ticket = (
"I was charged twice for my subscription this month "
"and I need a refund today. This is unacceptable."
)
result = app.invoke({"ticket": urgent_ticket})
print("Category:", result["category"])
print("Priority:", result["priority"])
print("Status: ", result["status"])
print("Reply: ", result["draft_reply"])Let's now check the result in the terminal. For that, we run the script with python triage.py:
Since this ticket has high priority, the router sends it to the human gate. We receive the status "escalated to human review" back, without an automatic response being created.
Let's take a look at a second ticket:
# A routine, low-stakes question
routine_ticket = "How do I change the email address on my account?"
result = app.invoke({"ticket": routine_ticket})
print("Category:", result["category"])
print("Priority:", result["priority"])
print("Status: ", result["status"])
print("Reply: ", result["draft_reply"])# A routine, low-stakes question
routine_ticket = "How do I change the email address on my account?"
result = app.invoke({"ticket": routine_ticket})
print("Category:", result["category"])
print("Priority:", result["priority"])
print("Status: ", result["status"])
print("Reply: ", result["draft_reply"])
This ticket flows to the responder and returns a drafted reply to us. The response can now be reviewed by a human and then sent out.
Alternative: Running the project via an API Model
If we now want to run the whole thing via an API model instead of locally, we only need to set an API key and change exactly one line. This is because we defined the factory function above.
To do this, first log in to the OpenAI API Platform: https://platform.openai.com/login
Then create a new API key and copy it. You will only see it once, so copy it right away.
For security reasons, we do not paste the key into our script. Instead, we store it as an environment variable, and the code reads it from there automatically. Run the command that matches your system in your terminal, replacing your-key-here with the key you just copied:
# On Windows (PowerShell)
setx OPENAI_API_KEY "your-key-here"
# On Mac/Linux
export OPENAI_API_KEY="your-key-here"# On Windows (PowerShell)
setx OPENAI_API_KEY "your-key-here"
# On Mac/Linux
export OPENAI_API_KEY="your-key-here"Hint for Newbies:_ You run these commands in your terminal, not in the script. It is convenient to use the same terminal where your virtual environment is active. Keep in mind, though, that the variable is set at the level of your terminal session, not inside the venv itself._
Once the key has been saved, you will see in your terminal that the operation was successful:
This way the key lives in exactly one place outside your code, never appears in the script, and cannot end up on GitHub by accident.
After that, you only need to change this one line in the script and can run the script again in your terminal with python triage.py.
# Switch from local to API without touching the graph or the agents
BACKEND = "api"
llm = get_llm(BACKEND)# Switch from local to API without touching the graph or the agents
BACKEND = "api"
llm = get_llm(BACKEND)Final Thoughts
What I find exciting about this example is that it mirrors a real workflow from our daily routine. Furthermore, it's not about replacing the support team, but about taking care of the mechanical sorting. The important or urgent cases, on the other hand, are handed over to a person.
Since we built the graph, the agents, and the routing to work both with Ollama and an API, you can, for example, prototype it with Ollama and then switch to an API model when you need more performance.
How could we further develop the system? We could give the agents a memory so that a follow-up ticket from the same customer carries over context. We could add more specialists, for instance, a dedicated billing agent with access to real account data. We could connect the graph to a real ticketing system via its API, so that this stops being just a script and becomes part of a real pipeline. Or we could build a proper human-in-the-loop step, where escalated tickets land in a review queue with the classification already attached.
Where Can You Continue Learning?
- Factory Method in Python — GeeksforGeeks
- LangGraph Overview — LangGraph Documentation
- LangChain Overview — LangChain Documentation
- LangGraph Agents Hands-On Tutorial — DataCamp
- Quickstart Ollama — Ollama Documentation
- CSV Plot Agent with LangChain & Streamlit: Your Introduction to Data Agents (the single-agent starting point on which this article is based) — Own Medium Article
- RAG in Action: Build your Own Local PDF Chatbot as a Beginner (more local LLM work with Ollama) — Own Medium Article