# Triage support tickets

Rank support tickets by urgency and send each one to the right team, in two calls.

Category: Handle customer messages. Live demo: https://levanto.ai/examples/ticket-triage

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.

## Setup

Base URL: `https://sage.levanto.ai`. Send `Authorization: Bearer $SAGE_API_KEY` on every call. Get a key, free, at https://platform.levanto.ai. Keep the key on your server.

A `null` answer is not an error: Sage2 isn't sure. Each step says what to do with it.

## Step by step

### 1. Rank the tickets by urgency

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.

```python
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"]
```

```typescript
const 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.",
  },
];

const order: string[] = (await sage("/decide", {
  content: { kind: "list", value: TICKETS },
  question: {
    id: "urgency",
    kind: "sort",
    instructions: "Sort by operational urgency, most urgent first.",
  },
})).result.sorted;
```

Response, abridged (a real run):

```json
{
  "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. Pick a team for every ticket

`/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`.

```python
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"])}
```

```typescript
const 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" },
  ],
};
const batch = await sage("/decide/batch", { requests: TICKETS.map(t => ({ content: t.content, questions: [TEAM_QUESTION] })) });

function chosenTeam(group: any): string | null {
  const answer = group.answers[0];
  return answer.ok ? answer.result.result.chosen : null;
}

const teamOf: Record<string, string | null> = Object.fromEntries(TICKETS.map((t, i) => [t.id, chosenTeam(batch.results[i])]));
```

Response, abridged (a real run):

```json
{
  "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. 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.

```python
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)
```

```typescript
for (const [i, ticketId] of order.entries()) {
  console.log(i + 1, ticketId, "->", teamOf[ticketId] ?? "a person");
}

const top = order[0];
console.log("Page", teamOf[top] ?? "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 `spam` team, 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.

## Full recipe (Python, `pip install requests`)

```python
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)
```

## Full recipe (TypeScript, Node 22+, nothing to install: save as `recipe.mts`, run `npx tsx recipe.mts`)

```typescript
// Node 22+. Save as recipe.mts and run npx tsx recipe.mts
async function sage(path: string, body: unknown): Promise<any> {
  const r = await fetch("https://sage.levanto.ai" + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.SAGE_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  return r.json();
}

const 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.",
  },
];

const order: string[] = (await sage("/decide", {
  content: { kind: "list", value: TICKETS },
  question: {
    id: "urgency",
    kind: "sort",
    instructions: "Sort by operational urgency, most urgent first.",
  },
})).result.sorted;

const 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" },
  ],
};
const batch = await sage("/decide/batch", { requests: TICKETS.map(t => ({ content: t.content, questions: [TEAM_QUESTION] })) });

function chosenTeam(group: any): string | null {
  const answer = group.answers[0];
  return answer.ok ? answer.result.result.chosen : null;
}

const teamOf: Record<string, string | null> = Object.fromEntries(TICKETS.map((t, i) => [t.id, chosenTeam(batch.results[i])]));

for (const [i, ticketId] of order.entries()) {
  console.log(i + 1, ticketId, "->", teamOf[ticketId] ?? "a person");
}

const top = order[0];
console.log("Page", teamOf[top] ?? "the on-call lead", "about", top);
```

## Requests

### 1. POST /decide

```bash
curl -s https://sage.levanto.ai/decide \
  -H "Authorization: Bearer $SAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "content": {
    "kind": "list",
    "value": [
      {
        "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."
      }
    ]
  },
  "question": {
    "id": "urgency",
    "kind": "sort",
    "instructions": "Sort by operational urgency, most urgent first."
  }
}
JSON
```

Response, abridged (a real run):

```json
{
  "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. POST /decide/batch

```bash
curl -s https://sage.levanto.ai/decide/batch \
  -H "Authorization: Bearer $SAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "requests": [
    {
      "content": "Checkout has returned a 500 error for every customer since 9:02. We are losing orders.",
      "questions": [
        {
          "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"
            }
          ]
        }
      ]
    },
    {
      "content": "There's a typo on your pricing page. 'Recieve' should be 'receive'. You're welcome.",
      "questions": [
        {
          "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"
            }
          ]
        }
      ]
    },
    {
      "content": "We were charged twice for November. Please fix this before our finance close on Friday.",
      "questions": [
        {
          "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"
            }
          ]
        }
      ]
    },
    {
      "content": "A customer's lawyer sent a data deletion request. We must answer within 30 days.",
      "questions": [
        {
          "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"
            }
          ]
        }
      ]
    },
    {
      "content": "Any plans for dark mode? Asking every month until you say yes.",
      "questions": [
        {
          "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"
            }
          ]
        }
      ]
    },
    {
      "content": "Nobody at our company can sign in since this morning. We have 400 seats.",
      "questions": [
        {
          "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"
            }
          ]
        }
      ]
    }
  ]
}
JSON
```

Response, abridged (a real run):

```json
{
  "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
    }
  }
}
```

## Docs

- Sort: https://docs.levanto.ai/decision-model/sort
- Choice: https://docs.levanto.ai/decision-model/choice
- Limits and errors: https://docs.levanto.ai/decision-model/limits
- OpenAPI: https://sage.levanto.ai/openapi.json
