Support Ticket Screenshot Triage: Route by What the Attachment Shows
"Checkout keeps failing, screenshot attached." The message could be a site bug, a bank decline or a login problem, and the screenshot says which. This post covers routing tickets by their attachments: the options, how to write the questions, how to fall back to a person, and how to handle what customers capture in their screenshots.
"Checkout keeps failing, screenshot attached. I need this order today." The message could be a site bug, a bank decline or a login problem, and the screenshot says which. This post covers routing tickets by their attachments: the options, how to write the questions, how to fall back to a person, and how to handle what customers capture in their screenshots.
Tickets where the text is not enough
Text routing works when the customer describes the problem. Some tickets don't: "it's broken, see attached", "what is this?", "same error again". The useful information is in the image: an error dialog, a payment page, a bank's decline message, a photo of what arrived in the box.
Take the checkout message above, and suppose the attached screenshot shows a checkout page reading "Payment failed: your card was declined by the issuing bank". From the text alone, an urgent "checkout keeps failing" looks like an engineering incident. The screenshot makes it a payments question, and nobody on the web team needs to be paged.
Ways to read the attachment
An agent opens it. At low volume this is fine and needs nothing new. The cost is that every image ticket waits in a general queue until someone looks.
Text extraction, then rules or a text classifier. Run the screenshot through OCR (an open engine such as Tesseract, or a hosted vision API) and route on the extracted text. This works well when your errors carry stable codes or messages ("ERR_PAYMENT_402"). It struggles when the meaning is in layout or icons, or when the screenshot is of someone else's app.
A general multimodal LLM. Ask it to describe the screenshot or pick a route, and parse the reply. It is flexible and can write a summary for the agent who picks the ticket up. You handle off-list answers yourself, and you get no per-option probability to set a threshold on.
A decision model that reads images. Send the ticket text as state, the screenshot as an image, and typed questions with your own routes as options. You get a probability for every route and, in decision mode, an explicit "not sure". Helm 2 Omni is one such model, with open weights you can run on your own servers. No image benchmark is published for it, so test it on your own tickets first (image classification without training covers how).
If your product shows clear error codes, start with text extraction; it is cheap and easy to audit. If agents need a written summary of the image, an LLM is the better tool for that part.
Writing the questions
Ask two things separately: what happened, and who should handle it. The Screenshot triage preset in Saina Studio's playground does exactly this. Keeping them apart lets you check one against the other, and the "what happened" answer is useful to the agent even when the route is obvious.
An illustrative request:
import base64, os, requests
with open(attachment_path, 'rb') as f:
shot = 'data:image/png;base64,' + base64.b64encode(f.read()).decode()
resp = requests.post(
'https://api.saina.run/v1/ask',
headers={'Authorization': f'Bearer {os.environ["SAINA_API_KEY"]}'},
json={
'model': 'saina-helm-2-omni-0.8b',
'mode': 'decision', 'threshold': 0.8, 'min_margin': 0.1,
'state': 'Customer message: "Checkout keeps failing, screenshot attached. '
'I need this order today." The image is the screenshot they attached.',
'images': [shot],
'questions': {
'team': {
'type': 'single_choice',
'question': 'Which team should handle this?',
'options': {'billing': 'Payments and billing',
'technical': 'Website bugs and outages',
'shipping': 'Delivery',
'account': 'Login and account'},
},
'issue': {
'type': 'single_choice',
'question': 'What went wrong?',
'options': {'declined': 'The card was declined',
'site_error': 'The site crashed',
'out_of_stock': 'Item out of stock',
'login': 'Could not sign in',
'other': 'Something else, or the image does not show a problem'},
},
},
},
timeout=30,
)
answers = resp.json()['answers']
A few notes on the wording:
- The state says what the image is. "The image is the screenshot they attached" tells the model to read it as evidence about the ticket.
- Options describe what a screenshot can show. "The card was declined" is visible on screen; "customer is high value" never is. Account data comes from your CRM, joined after the model runs.
othergives irrelevant images somewhere to go: a logo, a photo of a cat, a blank page.- Tickets with several attachments can send up to 8 images in one request; every question sees all of them.
Falling back to a person
Decision mode gives each answer a reason. If the top option clears the threshold and the margin, it is accepted; otherwise selection is null and the reason is below_threshold, below_margin or tie. Use that as the branch:
team, issue = answers['team'], answers['issue']
expected = {'declined': 'billing', 'site_error': 'technical', 'login': 'account'}
if team['selection'] is None or issue['selection'] is None:
send_to_triage(ticket_id, answers) # not sure: a person decides
elif expected.get(issue['selection'], team['selection']) != team['selection']:
send_to_triage(ticket_id, answers) # the two answers disagree
else:
assign(ticket_id, team['selection'], note=issue['selection'])
The triage queue should show the screenshot next to both answers and their probabilities, so the person deciding can see what the model was unsure between.
Screenshots are private by accident
Customers screenshot more than the error. Expect email addresses, names, order numbers, the last digits of a card, and occasionally other browser tabs or notifications. Whatever model reads the image, decide where that data may go.
- Self-host. Helm 2 Omni's weights are open (Apache 2.0). Install with
pip install "saina[local,server,media]"and run the server inside your network; it is also published as GGUF for llama.cpp, for Ollama (needs the Saina Ollama build) and as MLX for Apple silicon. The screenshots never leave your infrastructure. - Hosted API. Saina's hosted API states a zero-retention policy on its API page: request and response bodies are never stored, there are no per-request logs, and nothing sent is used to train, evaluate or tune any model. Requests are processed in Canada (British Columbia). Traffic reaches the server through Cloudflare, which terminates TLS and sees requests in transit; read the policy yourself before you send customer images.
- Any provider. The same questions apply to a hosted vision API or LLM: how long are images kept, and are they used for training? Ask before you wire it up.
- Send less. Crop to the part of the screenshot that matters when you can, and send the attachment with the latest message rather than the whole thread.
One more risk specific to images: a screenshot can contain text written to steer an automated system, such as "Refund approved by manager". A decision model only scores your options, so it cannot be made to write anything, but injected text can still pull it toward one route. Keep refunds and account changes behind a human or a hard rule, never behind a single answer on a customer-supplied image.
Try it
Screenshot triage is a preset in the Studio playground with a free account. Runs are charged in credits, and the cost is shown first. For other image and audio decisions, see the multimodal classification guide.