Multi-Agent Support Ticket Triage With LangGraph and Ollama
Build a three-agent ticket triage graph with LangGraph: a classifier, a responder and a human gate, running on local Ollama or an API with one changed line.
This article is published in English.
Sources: Building a Multi-Agent Support Ticket Triage With LangGraph
Anyone who has staffed a support inbox knows the morning routine. Somebody reads every new ticket, sorts the password resets and billing questions into piles, and hunts for the few messages that are truly urgent. It's purely mechanical, and it's exactly the kind of work a small team of cooperating agents can take over.
What follows builds that team with LangGraph. One agent classifies each ticket, a router decides what happens next and a second agent drafts a reply. Urgent cases skip automation and go to a person. The graph is written once and runs either locally on Ollama or against a cloud API, with no change to the agents or the routing.
Why more than one agent
A lone agent is like a worker who never talks to anyone, and in a real company that stops scaling quickly. Add more tasks and the single prompt grows long, brittle and hard to debug, because one model is asked to classify, decide, write and judge risk all at once. Splitting the job lets each piece stay small, testable and replaceable. A multi-agent system resembles a team of specialists, each with a defined role, coordinating the next move.
Ticket triage suits the idea because the work naturally forks, and a pipeline that handles every message identically would be wrong for at least one of the cases below:
- A routine billing question can be answered automatically.
- A vague feature request gets a drafted reply that a human refines later.
- A message like "my application is down" shouldn't be automated at all and should reach a human right away.
LangGraph handles exactly these forks, where the next step depends on the current state. The team has three members:
- Classifier agent, which reads a ticket and assigns a category and a priority.
- Responder agent, which drafts replies for tickets that are safe to process automatically.
- Human gate, a node that hands urgent tickets to a person rather than replying itself.
Between them sits the routing logic, effectively a supervisor. It looks at the priority and picks the path.
How LangGraph thinks
Picture a flowchart on a whiteboard. LangGraph has four parts:
- State is a clipboard passed from station to station. It carries everything known about the ticket so far: the original text, category, priority, the drafted response if any, and status.
- Node is a station, a function that takes the state, does a job and returns updates.
- Edge is a fixed arrow from one station to the next.
- Conditional edge is an arrow chosen at runtime. A small router function reads the state and decides where to go, instead of always heading to the same place.
So state flows through nodes, edges connect them, and conditional edges let the path branch depending on what the state currently says. If you're new to this, the state is just a Python dictionary with a defined shape. A TypedDict describes which keys the dictionary has.
The tech stack
LangGraph is the orchestration layer. It defines the nodes, the state and the routing between agents.
Ollama with Llama 3 runs everything locally, free of charge, on your own machine. Support tickets often contain personal customer data, so in a business setting there's a real advantage in keeping that data on your own infrastructure instead of sending it to an outside API.
An API model, here gpt-4o-mini, is the alternative when you have no spare hardware or need more performance than a small local model delivers. Because the graph is written once, you can start locally and move to an API later without rewriting logic.
Building it step by step
1. Setup
Install three packages: langgraph for the graph, langchain-ollama as the bridge to Ollama and langchain-openai as the bridge to the API model.
# Install LangGraph and the two model integrations
pip install langgraph langchain-ollama langchain-openai
A fresh virtual environment keeps this project's packages away from your others. On Windows, create it with python -m venv yourname and activate it through the Scripts folder inside. On Mac or Linux, use python3 -m venv venv followed by source venv/bin/activate. Once it's active, the install command above only affects this project.
If Ollama isn't installed yet, get it from ollama.com/download. Then pull the local model from the terminal:
# Download the Llama 3 model so we can run it locally
ollama pull llama3
2. The agents
From here on, everything goes into a single file named triage.py, added top to bottom in the order below.
A factory for the model. Start with the piece that makes both backends possible, a small factory function, get_llm, that returns the right model based on one setting. A factory's only job is to hand back a finished object. Think of a coffee machine with a button marked "local" and another marked "API": press one, get the matching cup, never learn what happens inside.
The function takes a backend parameter whose default is "local", so calling it with no argument uses the local model.
# 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)
An if/elif/else chain checks the parameter and returns either llama3, gpt-4o-mini or an error. The imports sit inside the branches on purpose. Someone working only locally never needs langchain-openai, and vice versa.
temperature=0 controls how random the model's answers are. Higher values add variety, while 0 makes replies as stable and predictable as possible. For classification that's exactly right, since the same ticket should reliably get the same category. BACKEND = "local" sets the choice, and the next line calls the factory.
The classifier. This agent reads a ticket and returns a category and a priority. To process the result programmatically, the model is asked to answer in JSON.
The parse_json helper turns the reply into a real Python dictionary. It uses find to locate the opening and closing braces first, because local models don't always return clean JSON. They like to wrap it in chatter such as "Sure, here is the JSON: ... I hope this helps."
The classify_ticket function is the agent itself. It receives the current state and returns updates. Its prompt gives the model a clear role as a support triage assistant, restricts the answer to four categories and three priorities, and demands pure JSON with no extra commentary. The prompt is deliberately strict so the reply comes back cleaner and parse_json has less to do. Calling llm.invoke(prompt) sends it to the model.
Notice what gets returned: only the two fields this agent fills in, category and priority. That's a core LangGraph trait. A node doesn't return the whole state, just the portions it wants to change, and LangGraph merges them into the shared state. It keeps nodes small, makes them easy to test on their own, and means one agent can't accidentally overwrite a field that belongs to another.
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"]}
The responder. The second agent drafts replies for tickets that are safe to automate. draft_reply receives the state, builds a prompt casting the model as a friendly, concise support agent, and adds a guardrail: no promises it can't keep. It returns two fields, the drafted text in draft_reply and the status "auto-drafted", so you can see later that the ticket was handled automatically and not escalated. The prompt is intentionally modest. A support reply written by a model should stay short and honest, and a promise about refund timing or a feature date is the kind of thing a person should decide.
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"}
The human gate. The third agent never calls the model. All it does is mark the ticket as escalated so that a person handles it. It returns an empty draft_reply, because no automatic response should exist, and the status "escalated to human review". That makes it explicit in the state that a human owns the ticket. A node doesn't have to use an LLM to count as an agent in the graph. Anything that takes the state, does a job and returns updates can sit on the same canvas, whether it calls a model, a database or nothing at all.
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
The three agents each have a job. Now they need connecting, and that starts with describing the shape of the shared state. The TicketState class is the clipboard from earlier, declaring which fields exist and their types.
Those five fields also trace a ticket's path through the system. ticket holds the original text. The classifier fills in category and priority. The responder fills in draft_reply, or leaves it empty after an escalation. status records where the ticket finally 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 up
Next comes the router, the supervisor. route_by_priority reads the priority the classifier assigned and returns a string telling LangGraph which path to take. If the priority is "high", the ticket goes to the human gate. Otherwise the function returns "responder" and the ticket gets an automatic reply.
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 assemble the graph:
- Create an empty graph with
StateGraph(TicketState), passing the state shape so the graph knows which fields are on the clipboard. - Register the three agents with
add_node. Each call pairs a short name with a function. The name "classifier" belongs toclassify_ticket, "responder" belongs todraft_reply, and "human_review" belongs toescalate_to_human. Every later step refers to the nodes by these names, so choose them carefully and keep them consistent. - Add
add_edge(START, "classifier")so every ticket begins at the classifier.STARTis a built-in entry point. - Add the conditional edge with
add_conditional_edges, which takes the node where branching happens ("classifier"), the router function (route_by_priority) and a lookup table. - Send both paths to
END, since a run is complete whether the ticket was answered or escalated. - Call
graph.compile(), which turns the definition into something executable. The result is stored inapp, and that's what runs tickets.
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()
The dictionary passed to add_conditional_edges is the lookup table. Its keys are the strings the router can return, and its values are the nodes those strings point to. When the router returns "human_review", LangGraph jumps to the human_review node. That mapping is the heart of how a graph branches. Add a new category of handling later, such as a billing specialist, and the change amounts to one new node, one new key in the table and a branch in the router.
4. Running it, local or API
Send an urgent ticket through the graph first:
# 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"])
Run the script with python triage.py. Expect the exact wording of the printed reply to vary a little between runs and between models, but the category, priority and status should be consistent, which is the reason for temperature=0. The ticket has high priority, so the router sends it to the human gate. The status comes back as "escalated to human review" and no automatic reply is produced.
Try a second, routine 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"])
This one flows to the responder and returns a drafted reply, which a human can review before sending. Running both tickets side by side makes the branching tangible: identical graph, identical code, two different routes decided purely by the priority the classifier assigned.
Switching to an API model
Running the same thing through an API instead of locally takes one API key and a single changed line, thanks to the factory function.
Log in to the OpenAI API platform, create a new key and copy it right away, since it's shown only once. For safety, don't paste the key into the script. Store it as an environment variable and let the code read it from there. Run the command for your system in a terminal, replacing your-key-here with the key:
# On Windows (PowerShell)
setx OPENAI_API_KEY "your-key-here"
# On Mac/Linux
export OPENAI_API_KEY="your-key-here"
Run these in the terminal, not in the script, and ideally in the same terminal where the virtual environment is active. The variable is set at the level of the terminal session, not inside the venv. After that, the key lives in one place outside your code, never shows up in the script and can't end up on GitHub by accident.
Then change one line in the script and run python triage.py again:
# Switch from local to API without touching the graph or the agents
BACKEND = "api"
llm = get_llm(BACKEND)
What it shows and where to go next
The example mirrors a real daily workflow. The goal isn't to replace the support team. It's to take over the mechanical sorting while important and urgent cases go to a person. Because the graph, agents and routing work with both Ollama and an API, you can prototype with Ollama and switch to an API model when more performance is needed.
There's plenty of room to grow it. Agents could get memory, so a follow-up ticket from the same customer carries context. More specialists could join, such as a billing agent with access to real account data. The graph could connect to an actual ticketing system through its API, turning the script into part of a real pipeline. And a proper human-in-the-loop step could place escalated tickets in a review queue with the classification already attached.
For further reading, the LangGraph and LangChain documentation at https://docs.langchain.com/oss/python/langgraph/overview and the Ollama quickstart at https://docs.ollama.com/quickstart cover the tools used here.