Skip to content

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
Try
  1. Rank the tickets by urgencySort
    1. checkout
    2. login
    3. invoice
    4. legal
    5. typo
    6. dark mode
    231 ms
  2. 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%
    121 ms
  3. 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

  1. Sort

    Rank the tickets by urgency

    Sort docs

    Send all the tickets as a list: content is {"kind": "list", "value": [...]}, each item with an id and its text. A Sort question takes only id, kind and instructions. The instructions say what to sort by.

    sorted lists the ids, most urgent first. confidence says 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
        }
      }
    }
  2. Choice

    Pick a team for every ticket

    Choice docs

    /decide/batch asks the same Choice question about every ticket in one call. Each item in requests is a group: one ticket as content, and the questions about it.

    Give the question a catch-all option, here support for account help and everything else, so every ticket has somewhere to go.

    results comes back in the same order as requests. Check ok on each answer before reading result.

    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
        }
      }
    }
  3. Your code

    Page whoever owns the top ticket

    Walk the ranked list and route each ticket. A ticket without a clear team (chosen is null) 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

  1. Sort takes 2 to 120 items. For a bigger inbox, rank the newest tickets, or rank within each team.
  2. Write urgency your way. "Revenue first, then legal deadlines" is a valid sort instruction.
  3. 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.
  4. Junk gets ranked too. Sort always returns an order, even for "!!!!". Add a spam team, and page only when the top ticket has a clear team.
  5. About 0.2 s. Ranking six tickets reads about 1,900 tokens. Routing them in one batch reads about 600.

Sage / Levanto

Start free.

Sign in or create an account.

Continue with Google
or with email

We’ll email you a 6-digit code.