How to Use Jev: A Practical Support Ticket Example
pip install typesafe-sdk, and an API key from the TypeSafe console. Store the key in TYPESAFE_API_KEY on the server rather than putting it in browser code.
Decide who does what
Before writing code, separate the three jobs. Jev classifies the ticket, your program decides whether the result is safe to use, and a language model writes the response. Keeping those jobs separate makes the behavior much easier to inspect.
| Step | Who does it | Example |
|---|---|---|
| Count open tickets, trim text, bucket a number | Code | open_tickets: "2 to 5" |
| Which queue, how frustrated, is a refund being asked for | Jev, one call | choice, score, noul |
| Assign, or stop for a human | Code, using a floor you set | confidence under 0.6 goes to review |
| Draft the customer reply | A language model, local or hosted | Only after the queue is chosen |
1. Prepare the state in code
Send the fields the questions will actually read. A raw export full of empty columns makes the judgment worse. Count in code, then hand Jev a word for the bucket. The question keys below are for your code. Jev never sees them, so the instruction text has to stand alone and point at fields with backticks.
def prepare(raw: dict) -> dict:
count = int(raw["open_ticket_count"])
if count <= 1:
bucket = "1"
elif count <= 5:
bucket = "2 to 5"
else:
bucket = "more than 5"
return {
"subject": raw["subject"].strip(),
"message": raw["message"].strip(),
"plan": raw["plan"],
"open_tickets": bucket,
}
2. Put every question in one place
One module holds the questions and the floors. When a label is wrong, you edit a sentence here, not a prompt buried in a route handler. Each Choice has an other option so a ticket that fits none of the queues is visible instead of forced into billing.
from typesafe_sdk import Choice, Noul, Score
DEPARTMENT_FLOOR = 0.6
REFUND_FLOOR = 0.7
ANGER_FLOOR = 0.6
QUESTIONS = {
"department": Choice(
instructions=(
"Which team should handle the ticket in `subject` and `message`? "
"Use `plan` and `open_tickets` only as context."
),
criteria={
"billing": "Payment, invoice, charge, or subscription problems",
"technical": "Bugs, outages, or a broken integration",
"sales": "Pricing, upgrades, or a new account",
"other": "None of those teams clearly owns it",
},
),
"frustration": Score(
instructions="How frustrated the customer appears in `message`",
criteria=[
"Calm, just stating facts",
"Frustrated but civil",
"Very angry, strong language",
],
),
"requests_refund": Noul(
instructions="The customer is asking for a refund or a charge to be reversed",
criteria={
"true": "They ask for money back or a reversed charge",
"false": "They want a fix, an answer, or something else",
},
),
}
requests_refund is a Noul, so the number you threshold is noul itself, from 0 to 1. There is no confidence field on a Noul. Choice and Score do return confidence, derived from how peaked their probability distribution is. Do not reuse REFUND_FLOOR as DEPARTMENT_FLOOR. They measure different things.
3. One call, then a policy
system_one defaults to jev-latest. Log response.model. After the floors below survive a labeled check, pass model="jev-1.13.0" (or whichever id came back) so an alias update cannot move your cutoff under you.
from typesafe_sdk import TypeSafeClient
def route(raw: dict) -> dict:
state = prepare(raw)
response = TypeSafeClient().system_one(state=state, questions=QUESTIONS)
answers = response.answers
department = answers["department"]
frustration = answers["frustration"]
refund = answers["requests_refund"].noul
if refund >= REFUND_FLOOR:
decision = {"action": "assign", "queue": "billing", "reason": "refund"}
elif department.choice == "other" or department.confidence < DEPARTMENT_FLOOR:
decision = {"action": "human-review", "queue": None, "reason": "low-confidence"}
else:
decision = {"action": "assign", "queue": department.choice, "reason": "choice"}
decision["priority"] = (
frustration.score >= 2 and frustration.confidence >= ANGER_FLOOR
)
decision["model"] = response.model
return decision
A ticket that exercises it:
print(route({
"subject": "Stripe sync broken",
"message": "My Stripe connection has failed for three days. I'm losing sales. Please help ASAP.",
"plan": "pro",
"open_ticket_count": 2,
}))
The 0.6 and 0.7 constants are starting guesses. TypeSafe does not publish a universal cutoff. A Choice of technical at confidence 0.51 should not take the same path as one at 0.9. Read probabilities when a ticket lands in review. That map tells you whether the model was split between two real queues or simply did not know.
frustration.score into a precise severity index. On Jev 1.13 a Score is safe as a threshold ("at least the angry level"). It is a weak way to reconstruct an exact number between two rubric levels.
4. Check the floor before you trust it
For an initial check, label 30 to 50 real tickets with the queue a person would pick: billing, technical, sales, or human-review. Run the same route function. Print two counts: auto-assigned rows that disagree with the label, and review rows that a person would have assigned cleanly. Move a floor, or a criteria sentence, only when you can show both numbers before and after.
labeled = [
{
"raw": {
"subject": "Charged twice",
"message": "I was charged twice for the same order. Please refund one.",
"plan": "pro",
"open_ticket_count": 1,
},
"expected": "billing",
},
# ...more rows you labeled, not rows the model labeled
]
wrong_auto = 0
auto = 0
for row in labeled:
got = route(row["raw"])
if got["action"] != "assign":
continue
auto += 1
if got["queue"] != row["expected"]:
wrong_auto += 1
print("MISS", got["queue"], row["expected"], row["raw"]["subject"])
print(f"{wrong_auto} wrong out of {auto} auto-assigned")
This small sample can expose obvious mistakes, but it cannot prove that automatic routing is reliable. Before the route affects customers, repeat the check on a larger set. If billing and technical trade the same tickets, the criteria overlap. Rewrite the situations, and add the borderline case to the option that should win. If everything falls through to review, the floor is high or the state is too thin. Change one of those, rerun, and keep the other still.
5. Let a model write only after the route exists
The reply is ordinary generation. Jev will not produce it, and chaining Choices to spell a sentence is the failure mode TypeSafe warns about. Once action is assign, call the model you already run locally — Ollama, LM Studio, or llama.cpp — with the queue and the priority as facts, not as a judgment you ask it to redo.
def draft_reply(state: dict, decision: dict) -> str:
if decision["action"] != "assign":
return "" # a person writes this one
# POST the prompt to your local server, for example Ollama on :11434.
# Tell it the queue and whether the ticket is priority.
# Do not ask it which team should handle the ticket.
...
A second Jev call is reasonable after the draft exists: one Noul, "Does this reply promise a refund?" If that probability is high and the route was not billing, drop the draft. That is a check on text the writer produced. It is not a second copy of the original routing questions.
Common first mistakes
- One question per HTTP call. Latency and cost both get worse, and the questions were designed to be scored together. Put them in one
questionsmap. - A single question that hides three judgments. "How should we handle this, and how urgent is it, and should we refund?" Split it. Combine the answers in
route. type: "boolean"on the TypeSafe API. That name belongs to Vercel's AI SDK. The native body andtypesafe-sdkusenoul. A boolean question againstPOST /v1/systemonefails validation.- Relying on the dictionary key. The key
requests_refundis not part of the prompt. Ifinstructionsis vague, the answer is vague. - Asking Jev for the email body, a count, or which date is later. Those belong to a language model or to ordinary code.
- Leaving
jev-latestin production after you tuned a floor. Pin the versioned id you measured.
Before you use it on real tickets
- Paste one real ticket into the playground and confirm the three questions return the shape you expect.
- Move that body into
prepare,QUESTIONS, androute. - Label a few dozen rows and print the auto-assign misses.
- Pin the model id. Only then call your local model to draft replies for the assigned queue.
- How many calls per ticket?
- One for the route. A later call is allowed when you are checking a draft, not when you are splitting the first three questions apart.
- What if the Choice is
otherbut confidence is high? - Still review it, or handle
otheron purpose. High confidence in "none of these" means the label set is missing a queue, not that a random queue is safe. - Can I use the JavaScript SDK instead?
- Yes.
npm install @typesafe-ai/sdk, thenclient.systemOne({ state, questions })withchoice(),score(), andnoul(). The policy function stays the same. Node 20 or newer. - Does this run on my GPU?
- The Jev call does not. The reply draft can. That split is the point.