Appearance
Support ticket triage
Triage of an inbound support ticket, decided by a model, rules and a support agent together in one durable workflow: your helpdesk sends the ticket with the customer, a model says whether it is spam, whether the help center answers it, what it is about and how urgent it is, five rules weigh those answers with the customer's tier and repeat tickets, and each ticket an agent must handle gets a summary the model wrote and a case at the priority its urgency gives. Every answer, rule result and decision stays on the record. Pack: examples/policies/support-ticket-triage/.
A pattern for a support triage decision, not customer-service advice: the thresholds, tiers, questions and SLAs are placeholders chosen to make the example run; a real policy comes from the business's own rules. Its model steps call an install named chatgpt (The chatgpt install).
The decision it automates
Whether a ticket needs an agent, and how soon, when your helpdesk starts the run. A ticket is not an application, so the outcomes mean:
approve: resolved without an agent. A how-to the help center answers, at routine urgency, from a customer on the free tier or a sender without an account: your helpdesk answers from its help center and closes the ticket, which the customer can reopen.reject: closed as spam, without a reply. Only when the model is at least 90% sure and the sender has no account: a customer's ticket is never closed on the model's answer alone.manual_review: an agent handles it, in asupport_ticketcase at the priority the urgency gives. The run waits for the agent, whose decision ends it with the same meanings:approveonce the ticket is resolved,rejectwhen it is closed without an answer (spam the rules let through, a duplicate).
Spam is a reject whoever closes it, the rules or an agent, so the outcome alone tells your helpdesk whether to reply. The pack writes no reply to the customer: on the approve path your helpdesk sends its own help center's answer.
Data expected
- The customer as a subject (kind
user) withtier(nonefor a sender without an account,free,proorenterprise) and the ticket underticket(subject,message,product,channel), which your helpdesk writes before it starts the run; the ticket's id as the run'scorrelationIdfinds the run again. Contact details such asemail,phoneandcompanymay sit beside them; the model never reads them. - A
ticket.createdevent for every ticket, this one included, posted throughPOST /events.
Steps and rules
| Step | Type | Why |
|---|---|---|
gate | branch | A ticket with a message goes on to classify; one without goes to an agent (case_medium), without the model or the rules. |
classify | call_app | chatgpt's decide, its state subject.ticket: the four questions below, answered under triage. |
rules | evaluate_rules | The support-ticket-triage rule set under max_severity, the model's answers among the data it weighs. |
route | branch | rules.outcome == manual_review goes on to the summary and a case; an approve or a reject ends the run. |
summarize | call_app | chatgpt's generate: an object of the step's schema, brief.output.summary and brief.output.nextStep, for the agent. |
prioritize | branch | triage.answers.urgency.value picks the case: 3 critical, 2 high, 1 medium, otherwise low. |
case_critical, case_high, case_medium, case_low | create_case | support_ticket at that priority, with SLAs of 1, 4, 24 and 72 hours; the topic is the case's routing label (queueFrom). |
decide | emit_decision | From the rules, or the agent's decision. |
The rule set support-ticket-triage (useCase: custom) uses max_severity: a failed block rule rejects, a failed warn rule sends the run to review, and the weights add up to the score. Three of its rules read the model's answers, so classify runs before it. The one block rule needs the model's answer and the sender's tier together; the model's other answers only warn.
| Rule | Type | Severity, weight | Fails when |
|---|---|---|---|
ticket_spam | expression | block, 100 | triage.answers.spam.probability is at least 0.9 and subject.tier is none; a missing tier is an error, which goes to review |
ticket_paying_customer | comparison | warn, 30 | subject.tier is pro or enterprise |
ticket_urgency | score threshold | warn, 30 | triage.answers.urgency.value is above 0 |
ticket_self_serve | comparison | warn, 20 | triage.answers.selfServe.value is not true: the model is less than 80% sure |
ticket_repeat | velocity | warn, 20 | more than two ticket.created events in 24 hours |
The model steps
classify asks four questions about one state, the ticket alone, which its input mapping takes as the decision's state (subject.ticket) (Asking for decisions):
| Question | Answer | Asks | Read by |
|---|---|---|---|
spam | yes or no | Whether the ticket is unsolicited marketing, a phishing attempt or asks nothing of support | ticket_spam |
selfServe | yes or no, threshold 0.8 | Whether the help center answers it on its own: how to do something the product already does | ticket_self_serve |
topic | how_to, account, billing, feature_request or bug | What the ticket is about | summarize, and the case's routing label |
urgency | a score on four levels | From routine (0), through soon and today, to now (3): many people blocked, or money or data at risk | ticket_urgency, prioritize |
summarize runs only on the way to a case. It asks generate for a TicketBrief from the ticket and the topic classify chose: what the customer needs, in two sentences at most (summary), and the one action the agent should take first (nextStep). Its instructions forbid promising a refund, a credit or a date (Asking for an answer). The brief is for the agent: nothing the model writes reaches the customer.
What the vendor sees
Through the install's connection, the steps' own texts and the ticket (subject, message, product, channel), with the topic for the summary: not the customer's name, contact details, company or tier (Data protection). Anyone can write a ticket, and a sender may write instructions into it. The model reads it as data, and an answer it was talked into changes only how that sender's own ticket is handled: closed as spam when they have no account, left to the help center when they are not a paying customer, or seen sooner or later by an agent. A paying customer's ticket always reaches one (Prompt injection).
The chatgpt install
A pack never carries an install: this one declares the install its steps call, "installs": { "chatgpt": "chatgpt" }, an install of the platform's ChatGPT (OpenAI API) app. The import reports chatgpt missing until the workspace has one (--publish publishes all the same), and a run that reaches classify without it fails with unknown_install. The admin console's import result installs it in one click, with a preset configuration that reaches OpenAI's Responses API with storing off, for decisions too, and a sample key (Installs at import and export).
OpenAI refuses the sample key. Until an administrator stores the workspace's own OpenAI key on the install's page (Connect › Apps, the install chatgpt, Configuration › Keys › openaiApiKey), every run of a ticket with a message fails at classify: it gets no decision and opens no case, and OpenAI's answer shows in the invocation record and under the run's app calls. The same holds whenever a call fails for good, summarize's included, through an outage that outlasts its retries, an exhausted quota or a refusal (Time, retries and failures). A ticket without a message never reaches the model, so its case opens all the same. Once the key is stored, replay a failed run from the step that failed (Operate › Runs, Replay from step: Admin console: Runs). Every model app takes the same input and answers in the same shape, so the steps can call another app's install instead, declared under its own name, or fall back on one; Jev decides only, so summarize needs one that also generates (Falling back to another install).
What a reviewer sees
A support_ticket case at the priority the urgency gave, labeled with the topic, with the ticket, the model's answers and probabilities, the summary and next step under brief, each rule's result, and the model's calls under Vendor checks. A case queue filtered on the label gives a team its tickets, billing to the billing team (Admin console: queues and filters); a breached SLA raises the priority one level and sends case.sla.breached (SLAs). The agent's decision ends the run: approve once the ticket is resolved, reject when it is closed without an answer.
How to adapt it
Read the tier and the account's health from your CRM instead of the subject: install the platform's http-api for its API, add a call_app step after gate that maps the customer's email into the request's variables, and point the tier rule at its answer (Adding a vendor step). Try a lower spam threshold as a shadow entry of the rule set first, recorded on every run but deciding nothing (Rules); label cases by tier instead of topic with queueFrom: "subject.tier"; and add a draftReply property to the summary's schema, for the agent to edit in your helpdesk before sending it, so a person stays between the model and the customer.
Run it
bash
pnpm policy:import examples/policies/support-ticket-triage --publishpnpm policies:check runs two fixtures against a model stub (scripts/ai-stub.mjs), whose answers a cue word in the message picks:
expect.json: an enterprise customer's ticket about checkout failing for every customer, cued high. The stub puts spam at 0.95, but the spam rule closes only tickets from senders without an account, so it passes; the urgency is at the top level and the tier and urgency rules fail, so the model summarizes the ticket and a criticalsupport_ticketcase labeledbugopens with a one-hour SLA; the check approves it and the run completes with a manualapprove.expect-spam.json: an SEO pitch from a sender without an account, cued high. Spam at 0.95 and no account fail the spam rule, and the run completes with an automatedreject, without a summary or a case.