Triage support tickets
Rank every ticket by urgency and send it to the right team.
A support inbox mixes outages with typo reports, and someone has to read all of it to find the fire. This guide does the reading in two calls: one ranks every ticket by urgency, the other sends each ticket to a team.
The checkout outage goes to the top and to engineering. The monthly dark mode request can wait.
- Sort
- Choice
- Rank the tickets by urgencySort
- checkout
- login
- invoice
- legal
- typo
- dark mode
- Pick a team for every ticketChoice
- checkout
- engineering 99.95%
- login
- engineering 97.30%
- invoice
- billing 99.94%
- legal
- legal 99.92%
- typo
- product 92.85%
- dark mode
- product 99.79%
- Your codeEngineering is paged
The top ticket is "checkout". The application pages engineering and routes the rest in rank order.
Sage2 ranked the tickets in 231 ms and is 56% confident in the whole order. The team questions ran as one batch: 121 ms.
Get the code
Set SAGE_API_KEY (get a free key), then run it. Python: pip install requests. TypeScript: Node 22+. Save as recipe.mts and run npx tsx recipe.mts.
A null answer is not an error. Sage2 isn't sure, and each step below says what to do with it. Limits, errors and retries: Limits and errors.
import os
import requests
def sage(path, body):
r = requests.post(
"https://sage.levanto.ai" + path,
headers={"Authorization": f"Bearer {os.environ['SAGE_API_KEY']}"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()
TICKETS = [
{
"id": "checkout",
"content": "Checkout has returned a 500 error for every customer since 9:02. We are losing orders.",
},
{
"id": "typo",
"content": "There's a typo on your pricing page. 'Recieve' should be 'receive'. You're welcome.",
},
{
"id": "invoice",
"content": "We were charged twice for November. Please fix this before our finance close on Friday.",
},
{
"id": "legal",
"content": "A customer's lawyer sent a data deletion request. We must answer within 30 days.",
},
{
"id": "dark_mode",
"content": "Any plans for dark mode? Asking every month until you say yes.",
},
{
"id": "login",
"content": "Nobody at our company can sign in since this morning. We have 400 seats.",
},
]
order = sage("/decide", {
"content": {"kind": "list", "value": TICKETS},
"question": {
"id": "urgency",
"kind": "sort",
"instructions": "Sort by operational urgency, most urgent first.",
},
})["result"]["sorted"]
TEAM_QUESTION = {
"id": "team",
"kind": "choice",
"instructions": "Which team should handle this ticket?",
"options": [
{"option": "engineering", "description": "Outages and bugs"},
{"option": "billing", "description": "Charges and invoices"},
{"option": "legal", "description": "Legal requests"},
{
"option": "product",
"description": "Feature requests and copy changes",
},
{
"option": "support",
"description": "Account help and everything else",
},
],
}
batch = sage("/decide/batch", {"requests": [{"content": t["content"], "questions": [TEAM_QUESTION]} for t in TICKETS]})
def chosen_team(group):
answer = group["answers"][0]
return answer["result"]["result"]["chosen"] if answer["ok"] else None
team_of = {t["id"]: chosen_team(g) for t, g in zip(TICKETS, batch["results"])}
for rank, ticket_id in enumerate(order, 1):
print(rank, ticket_id, "->", team_of[ticket_id] or "a person")
top = order[0]
print("Page", team_of[top] or "the on-call lead", "about", top)
How it works
Send all the tickets as a list:
contentis{"kind": "list", "value": [...]}, each item with anidand its text. A Sort question takes onlyid,kindandinstructions. The instructions say what to sort by.sortedlists the ids, most urgent first.confidencesays how far to trust the whole order.Runs with the helper from Get the code.
TICKETS = [ { "id": "checkout", "content": "Checkout has returned a 500 error for every customer since 9:02. We are losing orders.", }, { "id": "typo", "content": "There's a typo on your pricing page. 'Recieve' should be 'receive'. You're welcome.", }, { "id": "invoice", "content": "We were charged twice for November. Please fix this before our finance close on Friday.", }, { "id": "legal", "content": "A customer's lawyer sent a data deletion request. We must answer within 30 days.", }, { "id": "dark_mode", "content": "Any plans for dark mode? Asking every month until you say yes.", }, { "id": "login", "content": "Nobody at our company can sign in since this morning. We have 400 seats.", }, ] order = sage("/decide", { "content": {"kind": "list", "value": TICKETS}, "question": { "id": "urgency", "kind": "sort", "instructions": "Sort by operational urgency, most urgent first.", }, })["result"]["sorted"]Example Sage2 output
{ "id": "urgency", "kind": "sort", "result": { "sorted": [ "checkout", "login", "invoice", "legal", "typo", "dark_mode" ], "confidence": 0.5603 }, "meta": { "model": "levanto-sage-2", "latency_ms": 231.2, "question_count": 1, "usage": { "billed_input_tokens": 1870, "image_count": 0, "image_tokens": 0, "input_tokens": 1870, "output_tokens": 0 } } }/decide/batchasks the same Choice question about every ticket in one call. Each item inrequestsis a group: one ticket ascontent, and thequestionsabout it.Give the question a catch-all option, here
supportfor account help and everything else, so every ticket has somewhere to go.resultscomes back in the same order asrequests. Checkokon each answer before readingresult.Runs with the helper from Get the code.
TEAM_QUESTION = { "id": "team", "kind": "choice", "instructions": "Which team should handle this ticket?", "options": [ {"option": "engineering", "description": "Outages and bugs"}, {"option": "billing", "description": "Charges and invoices"}, {"option": "legal", "description": "Legal requests"}, { "option": "product", "description": "Feature requests and copy changes", }, { "option": "support", "description": "Account help and everything else", }, ], } batch = sage("/decide/batch", {"requests": [{"content": t["content"], "questions": [TEAM_QUESTION]} for t in TICKETS]}) def chosen_team(group): answer = group["answers"][0] return answer["result"]["result"]["chosen"] if answer["ok"] else None team_of = {t["id"]: chosen_team(g) for t, g in zip(TICKETS, batch["results"])}Example Sage2 output
{ "results": [ { "answers": [ { "ok": true, "result": { "id": "team", "kind": "choice", "result": { "chosen": "engineering", "probability": 0.9995, "probabilities": [ { "option": "engineering", "probability": 0.9995 }, { "option": "billing", "probability": 0.0002 }, { "option": "legal", "probability": 0 }, { "option": "product", "probability": 0 }, { "option": "support", "probability": 0.0002 } ] }, "meta": { "model": "levanto-sage-2" } }, "error": null } ] }, { "answers": [ { "ok": true, "result": { "id": "team", "kind": "choice", "result": { "chosen": "product", "probability": 0.9285, "probabilities": [ { "option": "engineering", "probability": 0.0475 }, { "option": "billing", "probability": 0.0049 }, { "option": "legal", "probability": 0.0004 }, { "option": "product", "probability": 0.9285 }, { "option": "support", "probability": 0.0187 } ] }, "meta": { "model": "levanto-sage-2" } }, "error": null } ] }, "… 4 more, one per request" ], "meta": { "model": "levanto-sage-2", "request_count": 6, "question_count": 6, "latency_ms": 121, "usage": { "billed_input_tokens": 646, "image_count": 0, "image_tokens": 0, "input_tokens": 646, "output_tokens": 0 } } }- Your code
Page whoever owns the top ticket
Walk the ranked list and route each ticket. A ticket without a clear team (
chosenisnull) goes to a person. The page also goes to a person if the top ticket has no owner.Runs with the helper from Get the code.
for rank, ticket_id in enumerate(order, 1): print(rank, ticket_id, "->", team_of[ticket_id] or "a person") top = order[0] print("Page", team_of[top] or "the on-call lead", "about", top)
Before you ship
- Sort takes 2 to 120 items. For a bigger inbox, rank the newest tickets, or rank within each team.
- Write urgency your way. "Revenue first, then legal deadlines" is a valid sort instruction.
- Watch
confidence. It's the trust in the whole order. Pick a bar on your own tickets. Below it, show the order as a suggestion. - Junk gets ranked too. Sort always returns an order, even for "!!!!". Add a
spamteam, and page only when the top ticket has a clear team. - About 0.2 s. Ranking six tickets reads about 1,900 tokens. Routing them in one batch reads about 600.