RunLocalModel.com

How to Use Jev: A Practical Support Ticket Example

By the RunLocalModel editorial team · September 23, 2026

What we are building We will route a support ticket to billing, technical support, sales, or a human reviewer. Jev makes the initial classification, but it does not send the ticket or write the reply. A few lines of ordinary Python decide what happens next. If Choice, Score, and Noul are new to you, read What Is Jev first.
You need Python 3.10 or newer, 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.

StepWho does itExample
Count open tickets, trim text, bucket a numberCodeopen_tickets: "2 to 5"
Which queue, how frustrated, is a refund being asked forJev, one callchoice, score, noul
Assign, or stop for a humanCode, using a floor you setconfidence under 0.6 goes to review
Draft the customer replyA language model, local or hostedOnly 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.

Do not turn 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

Before you use it on real tickets

  1. Paste one real ticket into the playground and confirm the three questions return the shape you expect.
  2. Move that body into prepare, QUESTIONS, and route.
  3. Label a few dozen rows and print the auto-assign misses.
  4. Pin the model id. Only then call your local model to draft replies for the assigned queue.
Common questions
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 other but confidence is high?
Still review it, or handle other on 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, then client.systemOne({ state, questions }) with choice(), score(), and noul(). 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.

Related guides on this site