How to Classify and Route Support Tickets Without GPT
Moving support classification and routing off GPT: your real options, why teams switch, and a step-by-step migration with shadow-mode validation and rollback.
This guide is for teams that want to classify and route support tickets without GPT. The usual reasons:
- Data: ticket contents can't go to a vendor.
- Vendor concentration: one provider's outage or policy change shouldn't take down triage. (A self-hosted server can go down too; the difference is that it's an outage you control and can add replicas for.)
- Cost, in specific cases: a self-hosted model is a flat cost, which pays off at very high volume; below that, hosted decision APIs are often cheaper. Run your own numbers before making cost the reason.
Below are the options that don't involve OpenAI, then a migration you can run one workflow at a time, with shadow mode before any cutover.
One note first: "off GPT" is not "GPT can't do this." OpenAI shipped its own decision model, GPT-6 Luna Decisions, on 2026-10-06, and it's a fair option (see the GPT-6 Luna Decisions alternatives guide). If you move off, do it for data control and vendor choice, not capability.
Ways to classify and route support tickets without GPT
The automated ticket routing guide compares the general approaches (rules, chat prompts, hosted and self-hosted decision models) in depth. Here's the shortlist when the constraint is "not OpenAI":
- Keyword rules, kept as hard overrides (legal keywords, VIP accounts) rather than as the engine.
- Fine-tuned open classifiers on your own hardware, if your label set is stable and you have labeled data and ML capacity.
- Open-weights decision models you self-host. Saina Helm is the worked example below: 0.8B parameters, single-choice, multi-label, yes/no and rating questions, 1–256 per call, decision mode with thresholds, margins and reason codes. It runs on CPU from the public Docker image
ghcr.io/run-saina/saina-server:0.1.2(about 4 GB RAM; 0.9–2.7 s per request in a local Docker test, 2026-10-06). Other open-weight decision models exist; evaluate any of them on your labeled set. - Other hosted providers. Anthropic and others serve the chat-classification pattern; for hosted decision APIs outside OpenAI, look at OpenRouter's Decisions API and TypeSafe's System One and Jev models. If the constraint is "not OpenAI" rather than "no data egress", they belong on the shortlist.
The migration, step by step
Step 1: Inventory. List every GPT call that returns a label, tag, yes/no or score. For each: prompt, model, volume/month, and what consumes the answer.
Step 2: Freeze the contract. Write down, per node: the option list (IDs + descriptions), the output shape, and the fallback behavior. This is what the replacement must honor. Then run the replacement against your labeled test set; the routing guide has the test-set and shadow-mode steps.
Step 3: Build the shadow. Run the replacement alongside GPT on live traffic, logging both answers, no routing action from the new one yet.
For Helm, the frozen contract becomes request data. An online store's support inbox, for example (illustrative):
from saina import Saina
client = Saina('https://helm.internal.example', api_key='YOUR_KEY')
# The option lists you froze in Step 2, reused as-is.
ROUTES = {'orders': 'Order status, changes, cancellations',
'returns': 'Returns, exchanges, refunds for purchased items',
'product': 'Questions about a product before or after buying',
'other': 'Anything that does not fit the queues above'}
TAGS = {'damaged': 'Item arrived broken or defective',
'late': 'Delivery is late or tracking has stalled',
'discount': 'Promo code or price-match question'}
result = client.ask(
model='saina-helm-0.8b',
state=ticket_text,
mode='decision',
questions={
'queue': {'type': 'single_choice',
'question': 'Which queue should handle this message?',
'options': ROUTES},
'tags': {'type': 'multi_choice',
'question': 'Which of these apply?',
'options': TAGS,
'threshold': 0.5}, # per tag; an example to tune
},
)
log_shadow(ticket_id,
old=gpt_label,
new=result['answers']['queue']['selection'], # None if rejected
new_reason=result['answers']['queue']['reason'],
new_tags=result['answers']['tags']['selections'])
A chat model with structured outputs can return a route and a tag list in one call too. What the decision model adds here is an independent membership score per tag and a reason code (below_threshold, below_margin, tie) on every rejected answer, which is what your review branch keys on.
Step 4: Review the disagreements. A high overall agreement rate can hide the cases that matter. Review every disagreement sample; classify each as new-system-wrong, old-system-wrong, or genuinely ambiguous. You're building the case file for cutover.
Step 5: Cut over per node, not wholesale. Switch one workflow to the new decision step with a fast rollback (feature flag or duplicate branch). Watch the fallback/review rate for a week before migrating the next node.
Step 6: Keep GPT where GPT earns it. Reply drafting, summarization and open-ended extraction stay on a chat model. The migration is about decision nodes.
Checklist
- Every decision node inventoried with its option list and fallback behavior.
- Replacement checked against your labeled test set before shadowing.
- Shadow mode logging both systems on live traffic.
- Disagreement samples reviewed and triaged (new wrong / old wrong / ambiguous).
- Cutover per node with rollback; review rate watched for a week each.
- Generation steps intentionally left on a chat model.