- Expanso + Jev
- Example 02 of 10
A support ticket router that knows when it is not sure.
Every ticket gets department, intent, frustration and urgency judged inline — confidently urgent pages on-call, uncertain ones queue for humans.
Where the record goes
Every stage on the left is Expanso, and it is deterministic. The record crosses to Jev once, for the one question a rule cannot answer, and comes straight back. Line numbers link to the YAML below.
POST /tickets- Receive
- Shape
- Ask Jev
- Gate
- Route
departmentintentfrustrationurgent
pageprioritytriageroute
- Receivelines 11 to 16
An HTTP server input accepts tickets on
POST /tickets. - Shapelines 17 to 26
Parses the POST body if it arrived as a string, then stamps
received_at. It applies the same fixed rules every time, with no model involved; the timestamp is the one value that differs. - Ask Jevlines 27 to 94
Expanso packs the whole record and the typed questions into one request. Jev answers. If the call fails, a
catchsubstitutes empty answers markedjev-unavailable, and the pipeline keeps going.departmentchoiceintentchoicefrustrationscoreurgentnoul
- Gatelines 95 to 113
Fixed thresholds over Jev’s answers choose page, priority, triage, or a department queue.
- Routelines 114 to 142
A
switchoutput writes to a file per decision, and per department for routed tickets.
What Jev is asked
Jev, judgmentThe pipeline sends the record with 4 typed questions. Jev answers each one with a value the pipeline can compare against a number.
departmentchoiceWhich department should handle this ticket?
billing · technical · sales · other
intentchoiceWhat is the customer trying to do?
bug · how_to · billing_dispute · feature_request · account_deletion · other
frustrationscoreHow frustrated does the customer sound?
urgentnoulDoes this message convey urgency or time-sensitivity?
What Expanso does with the answers
Expanso, deterministicFixed thresholds, checked in order. The first rule that matches sets the route. These are the expressions in the pipeline, not a summary of them.
- $urgent >= 0.9page · Confidently urgent.
- $frustration >= 2.5priority · Frustrated or worse.
- $dept_conf < 0.5triage · Jev is unsure which department owns it.
- elseroute · Goes to the department Jev chose.
If Jev is unreachable: triage
With no answers, department confidence defaults to 0, which is under 0.5, so the ticket goes to human triage.
The pipeline
This is the example's own pipeline file, unmodified. Violet marks the lines Expanso runs on its own. Orange marks the handoff, and the darker orange band is the HTTP call to Jev itself.
name: jev-ticket-router
type: pipeline
description: Support ticket router with Jev — every ticket gets department, intent, frustration score and urgency judged inline. Confidently urgent tickets page on-call; uncertain ones queue for humans.
namespace: production
labels:
category: integrations
pattern: ai-decision
model: jev
config:
input:
http_server:
address: "0.0.0.0:8080"
path: /tickets
allowed_verbs: ["POST"]
pipeline:
processors:
- mapping: |
# http_server already parses JSON bodies; only parse raw strings
root = if this.type() == "string" { this.parse_json() } else { this }
- mapping: |
root = this
root.received_at = now()
# ── Ask Jev ──
- mutation: |
meta jev_start = timestamp_unix_milli()
- branch:
request_map: |
root = {
"state": this.string(),
"model": "jev-latest",
"questions": {
"department": {
"type": "choice",
"instructions": "Which department should handle this ticket?",
"criteria": {
"billing": "Charges, invoices, refunds, subscription changes",
"technical": "Bugs, errors, how-to questions, integrations",
"sales": "Pricing questions, upgrades, new business",
"other": "Does not fit any department above"
}
},
"intent": {
"type": "choice",
"instructions": "What is the customer trying to do?",
"criteria": {
"bug": "Reporting something broken",
"how_to": "Asking how to accomplish something",
"billing_dispute": "Disputing a charge or invoice",
"feature_request": "Asking for new functionality",
"account_deletion": "Wants their account or data deleted",
"other": "None of the above"
}
},
"frustration": {
"type": "score",
"instructions": "How frustrated does the customer sound?",
"criteria": [
"Calm, just stating facts",
"Mildly annoyed",
"Frustrated but civil",
"Very angry, strong language",
"Furious, threatening to leave"
]
},
"urgent": {
"type": "noul",
"instructions": "Does this message convey urgency or time-sensitivity?"
}
}
}
processors:
- http:
url: "${JEV_API_URL:https://api.typesafe.ai/v1/systemone}"
verb: POST
headers:
Content-Type: application/json
Authorization: "Bearer ${TYPESAFE_API_KEY}"
timeout: 2s
retries: 1
- catch:
- mapping: |
root = {"answers": {}, "model": "jev-unavailable"}
result_map: |
root.jev = {
"answers": this.answers,
"model": this.model.or("jev-latest"),
"ms": timestamp_unix_milli() - metadata("jev_start")
}
# ── Confidence-gated cascade ──
- mapping: |
root = this
let dept = this.jev.answers.department.choice.or("other")
let dept_conf = this.jev.answers.department.confidence.or(0)
let frustration = this.jev.answers.frustration.score.or(0)
let urgent = this.jev.answers.urgent.noul.or(0)
root.jev_decision = if $urgent >= 0.9 {
"page"
} else if $frustration >= 2.5 {
"priority"
} else if $dept_conf < 0.5 {
"triage"
} else {
"route"
}
root.assigned_department = $dept
output:
broker:
pattern: fan_out
outputs:
- stdout:
codec: lines
- switch:
cases:
# Production: swap for http_client -> on-call paging webhook
- check: this.jev_decision == "page"
output:
file:
path: ./data/jev-ticket-router/page-${! now().ts_format("2006-01-02") }.jsonl
codec: lines
- check: this.jev_decision == "priority"
output:
file:
path: ./data/jev-ticket-router/priority-${! now().ts_format("2006-01-02") }.jsonl
codec: lines
- check: this.jev_decision == "triage"
output:
file:
path: ./data/jev-ticket-router/triage-${! now().ts_format("2006-01-02") }.jsonl
codec: lines
- check: "true"
output:
file:
path: ./data/jev-ticket-router/routed-${! this.assigned_department }-${! now().ts_format("2006-01-02") }.jsonl
codec: lines
142 lines. Copy and Download both give you the file byte for byte.
What you need
- Expanso Edge installed, to validate and run the pipeline.
- A Jev endpoint. The pipeline posts to
JEV_API_URL, and falls back tohttps://api.typesafe.ai/v1/systemonewhen that variable is unset. - A key for that endpoint in
TYPESAFE_API_KEY. The pipeline sends it as a bearer token and has no default for it.
What it proves
- Input. POST /tickets on port 8080.
- Output. Local files under
./data/jev-ticket-router/. A comment in the YAML marks where an on-call paging webhook replaces the page file in production. - Scope. This example ships as a pipeline file and sample records. Its README marks the live runtime (event generator and dashboard) as coming next, so what is published here is the pipeline itself.
- Revision. The file shown is the example as of commit
517c38fof its repository, which is still being developed.
Sample records
The first two of the records that ship with this example. Download all of them.
{"id": "tkt-1001", "customer": "acme-corp", "subject": "Invoice #INV-2041 charged twice", "body": "Hi, we were charged twice for invoice INV-2041 ($4,200 each). This looks like a duplicate charge \u2014 please refund it when you get a chance."}{"id":"tkt-1002","customer":"globex","subject":"API returns 500 on webhook delivery","body":"Our webhook endpoint started receiving 500s from your API about an hour ago. Nothing changed on our side. Can someone take a look?"}Questions about this example.
receive, shape, gate, route. Each of those stages applies the same fixed rules every time, with no model involved. Expanso also sets the thresholds that turn Jev's answers into a route.
Jev answers 4 typed questions about each record: department, intent, frustration, urgent. It does not choose the route. The pipeline's gate does that from Jev's answers.
The pipeline catches the failed call, marks the record jev-unavailable, and the gate resolves to "triage". With no answers, department confidence defaults to 0, which is under 0.5, so the ticket goes to human triage.
Run the deterministic half on your own nodes.
Expanso Edge runs these pipelines where the data is created. The first five nodes are free.