How to Build a Claude-Powered Ticket Triage Bot
A ticket triage bot built on Claude reads incoming support tickets, classifies them by category and urgency, extracts key facts, and routes them to the right queue or person — without a human touching every single one first. You build it by sending ticket text to Claude with a structured prompt, forcing a predictable JSON output, and wiring that output into your helpdesk's routing rules.
The hard part isn't calling the model. It's getting consistent, machine-readable output every time, handling edge cases (spam, duplicates, VIP accounts), and keeping the whole pipeline cheap enough to run on every ticket you receive. Below is a practical build you can adapt to Zendesk, Freshdesk, Linear, or a homegrown queue.
What "triage" actually means for a bot
Before writing code, define what decisions the bot needs to make. Most triage bots need to output:
- Category — billing, bug, feature request, account access, abuse, other
- Priority — low, medium, high, urgent
- Sentiment — neutral, frustrated, angry
- Summary — one sentence for the agent dashboard
- Suggested team — which queue it should land in
- Confidence — whether the bot is sure enough to auto-route, or should flag for human review
If you can't list these fields clearly, the prompt will be vague and the output will be inconsistent. Write the schema first, prompt second.
Architecture overview
A minimal pipeline looks like this:
- Ticket arrives via webhook (email, form, Slack, chat widget)
- Your backend sends the ticket text + metadata to Claude
- Claude returns structured JSON matching your schema
- Your backend applies business rules (e.g., "urgent" + "billing" → page on-call)
- Ticket gets tagged and routed in your helpdesk via its API
The only part Claude handles is step 2 and 3 — classification and extraction. Routing logic stays in your code, where it's testable and auditable.
Writing a prompt that returns structured output
Ask for JSON explicitly and give Claude a fixed set of allowed values. Vague prompts ("categorize this ticket") produce inconsistent labels over time; constrained prompts don't.
You are a support ticket triage assistant. Given a ticket, return ONLY valid JSON with this shape:
{
"category": "billing" | "bug" | "feature_request" | "account_access" | "abuse" | "other",
"priority": "low" | "medium" | "high" | "urgent",
"sentiment": "neutral" | "frustrated" | "angry",
"summary": string,
"suggested_team": "support" | "engineering" | "billing_ops" | "trust_and_safety",
"confidence": number between 0 and 1
}
Ticket subject: {{subject}}
Ticket body: {{body}}
Customer plan: {{plan}}
Keep the instruction set small. The more rules you cram into one prompt, the more likely Claude drifts on edge cases — better to add a second pass for anything genuinely ambiguous.
Calling the API
If you're already on an Anthropic subscription and don't want to juggle separate API billing, you can route this through SubToAPI, which turns your existing Claude access into a standard HTTPS API with application keys, usage metadata, and team seats. Setup is a signup plus one API call — see /docs/quickstart.
curl https://api.subtoapi.app/v1/messages \
-H "Authorization: Bearer $SUBTOAPI_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4-5",
"max_tokens": 300,
"messages": [
{
"role": "user",
"content": "Triage this ticket and return JSON only.\n\nSubject: Refund not received\nBody: I cancelled last month but was charged again today. This is the third time.\nPlan: Pro"
}
]
}'
In JavaScript, wrap the call so the JSON parse failure path is explicit — this is the thing that actually breaks triage bots in production:
async function triageTicket(subject, body, plan) {
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUBTOAPI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: 300,
messages: [
{
role: "user",
content: `Triage this ticket and return JSON only.\n\nSubject: ${subject}\nBody: ${body}\nPlan: ${plan}`,
},
],
}),
});
const data = await res.json();
const text = data.content[0].text;
try {
return JSON.parse(text);
} catch {
return { category: "other", priority: "medium", confidence: 0, needs_human_review: true };
}
}
Full request and response shape is documented at /docs/messages.
Letting Claude look things up before deciding
Simple text triage gets you far, but real triage often needs context the ticket text doesn't contain — customer plan tier, open ticket count, account age. Rather than stuffing all of that into every prompt, use tool calling so Claude only fetches what it needs for ambiguous cases:
get_customer_account(email)— plan, signup date, past ticketscheck_known_incident(keywords)— is this part of an ongoing outageget_sla_deadline(account_id)— contractual response time
Tool use lets the bot ask "is this customer on an SLA plan?" before deciding priority, instead of you pre-fetching data for every single ticket regardless of whether it's needed. See /docs/tools for the request format.
Handling the output
Once you have structured JSON, routing is plain code:
if (result.confidence < 0.6) {
routeTo("human_review_queue");
} else if (result.priority === "urgent" && result.category === "billing") {
routeTo("billing_ops", { page_oncall: true });
} else {
routeTo(result.suggested_team);
}
Always keep a "send to human" fallback for low-confidence results. A triage bot that's wrong 5% of the time and silently misroutes tickets is worse than one that flags uncertainty and asks for a human to glance at it.
Testing and monitoring
Collect a set of real past tickets with known correct labels and run them through the bot whenever you change the prompt. Track:
- Classification agreement rate vs. human-labeled tickets
- Average confidence score over time
- Rate of tickets sent to human review
- Cost per ticket classified
For teams running this across multiple support agents or environments, usage breakdowns by key help separate staging traffic from production load — check /pricing for plan limits if you're scaling beyond a few thousand tickets a month.
Keep it narrow
Resist the urge to have the same call also draft a reply, summarize sentiment trends, and suggest KB articles. One prompt, one job — classify and route. Add a second, separate call for drafting responses once triage is stable. Bots that try to do everything in one shot are the ones that silently degrade when ticket volume or phrasing shifts.
Questions
Does the bot need to see the full ticket history, or just the latest message? For routing decisions, the latest message plus account metadata is usually enough. Full thread history helps for sentiment on repeat contacts, but it increases token cost — only fetch it via a tool call when the first pass is ambiguous.
What happens if Claude returns malformed JSON? Treat it as a parse failure, not a crash. Fall back to a default "needs human review" classification and log the raw response so you can see which prompt patterns cause drift.
Can this run in real time on every incoming ticket? Yes — classification calls are small (a few hundred tokens) and fast enough to run synchronously in a webhook handler. For very high volume, queue tickets and process them asynchronously so a slow response doesn't block your webhook receiver.