Route support tickets with Jev: four queues, missing details and human review

Build a small, testable ticket router: a Choice for billing, technical, sales or other, a Noul for missing details, and code that decides when a person reviews.

What this guide shows: request formats and application code follow the TypeSafe documentation checked on 2026-09-25. The tickets, labels and thresholds are synthetic examples of business rules. This page reports no model outputs, accuracy, speed or cost comparisons; measure on your own labeled data before relying on a result.

1. Decide what the router may do

The router in this guide does two things: it picks a queue and it flags missing details. It does not write replies, issue refunds or change accounts; keep those actions behind your normal permissions. Jev returns typed answers. A Choice returns the selected option, a probability for each option and a confidence value. A Noul returns the probability that the answer is yes, from 0 to 1. That structure lets ordinary code make the decisions below.

2. Write the four queue boundaries

These are example business rules for illustration. Replace them with how your teams actually divide the work.

QueueOwnsHand off when
billingExisting charges, invoices, refunds, payment methods, failed renewalsThe customer asks about prices before buying (sales)
technicalBugs, outages, error messages, login problems, integration failuresThe problem is a charge that already happened (billing)
salesPricing questions, plan comparisons, new purchases, quotesThe customer already pays and asks about a charge (billing)
otherPartnerships, press, job applications, general feedbackNever auto-closed; a person reads it

Put these boundaries into the option descriptions. TypeSafe's Jev 1.13 limitations page says the model reads instructions literally: when you catch yourself explaining what you really meant after a wrong answer, that explanation is the missing half of the criteria. Keep other as a real option, so a ticket that fits no team is not forced into the closest queue.

3. Start from the lab request

Open the support-routing recipe in the request lab and it loads the request below. Edit the message, then copy JSON or cURL. You only need your own key to send it.

{
  "model": "jev-1.13.0",
  "state": {
    "message": "I was charged twice for the same subscription this month."
  },
  "questions": {
    "decision": {
      "type": "choice",
      "instructions": "Which team should handle this request? Choose other if none of the specific teams fits.",
      "criteria": {
        "billing": "Payments, charges, invoices or refunds",
        "technical": "Bugs, outages or integration errors",
        "sales": "Pricing or a new purchase",
        "other": "A request outside these teams"
      }
    }
  }
}

The cURL version reads the key from an environment variable and passes the body through a quoted heredoc:

curl --fail-with-body --max-time 20 https://api.typesafe.ai/v1/systemone \
  -H "Authorization: Bearer $TYPESAFE_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JEV_ATLAS_REQUEST'
{
  "model": "jev-1.13.0",
  "state": {
    "message": "I was charged twice for the same subscription this month."
  },
  "questions": {
    "decision": {
      "type": "choice",
      "instructions": "Which team should handle this request? Choose other if none of the specific teams fits.",
      "criteria": {
        "billing": "Payments, charges, invoices or refunds",
        "technical": "Bugs, outages or integration errors",
        "sales": "Pricing or a new purchase",
        "other": "A request outside these teams"
      }
    }
  }
}
JEV_ATLAS_REQUEST

In production, ask for the queue and the missing-details check in one request. Both questions are evaluated independently against the same state; one answer is not context for the other, so your code combines them. team and needs_details are IDs you choose, and the answers come back under the same IDs.

{
  "model": "jev-1.13.0",
  "state": {
    "subject": "Charged twice this month",
    "message": "I was charged twice for the same subscription this month. The invoice numbers end in 4471 and 4472.",
    "channel": "email"
  },
  "questions": {
    "team": {
      "type": "choice",
      "instructions": "Which team should handle this support request? Judge the customer's main request. Choose other if none of the specific teams fits.",
      "criteria": {
        "billing": "Existing charges, invoices, refunds, payment methods or failed payments on an existing account",
        "technical": "Bugs, outages, error messages, login problems or integration failures",
        "sales": "Pricing questions before purchase, plan comparisons, new purchases or quotes",
        "other": "Anything outside these three teams, such as partnerships, press or general feedback"
      }
    },
    "needs_details": {
      "type": "noul",
      "instructions": "Does this request lack a detail that a support agent needs before starting work, such as which account, order, product or error it is about?",
      "criteria": {
        "true": "At least one detail needed to start work is missing",
        "false": "The request identifies what it is about well enough to start work"
      }
    }
  }
}

4. Handle missing information explicitly

The needs-clarification recipe asks one Noul: does the request lack what is needed?

{
  "model": "jev-1.13.0",
  "state": {
    "request": "Please send it to the other address.",
    "known_context": "No document or recipient address has been specified."
  },
  "questions": {
    "decision": {
      "type": "noul",
      "instructions": "Does this request need additional context to identify both what should be sent and its destination?",
      "criteria": {
        "true": "At least one required detail is missing",
        "false": "Both the item and destination are explicitly identified"
      }
    }
  }
}

A Noul is an estimate, from 0 to 1, of the probability that the answer is yes, and it carries no confidence value. When it is high, do not let the model guess a queue: ask the customer for the specific missing detail. Keep exact checks in code. If your form requires an order number or account email, check that field before calling the model.

5. Let code decide thresholds and human review

// Node 18+ or any runtime with fetch. Example thresholds: calibrate them on your
// own labeled tickets; they are starting points, not measured values.
const ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
const ROUTE_AT_CONFIDENCE = 0.8;
const ASK_AT_MISSING = 0.7;
const TEAMS = new Set(['billing', 'technical', 'sales']);

export async function classifyTicket(request, apiKey) {
  const response = await fetch(ENDPOINT, {
    method: 'POST',
    headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(request),
    signal: AbortSignal.timeout(20_000),
  });
  if (response.status === 429 || response.status === 529) {
    // Back off; honor retry-after when present. Do not retry in a tight loop.
    const error = new Error('typesafe_retry_later');
    error.retryAfter = response.headers.get('retry-after');
    throw error;
  }
  if (!response.ok) throw new Error(`typesafe_http_${response.status}`);
  return response.json();
}

export function decideRoute(result) {
  const team = result.answers.team;
  const missing = result.answers.needs_details.noul;
  if (missing >= ASK_AT_MISSING) return { action: 'ask_customer', reason: 'missing_details' };
  if (!TEAMS.has(team.choice)) return { action: 'human_triage', reason: 'no_specific_team' };
  if (team.confidence < ROUTE_AT_CONFIDENCE) {
    return { action: 'human_triage', reason: 'low_confidence', suggestion: team.choice };
  }
  // Log the versioned model that answered, so threshold changes stay traceable.
  return { action: 'route', queue: team.choice, confidence: team.confidence, model: result.model };
}

The three thresholds are example starting points, not recommendations. TypeSafe suggests splitting confidence into high, medium and low ranges and scaling thresholds with risk. A misrouted ticket costs less than an automated refund, so actions with more impact deserve a stricter gate. Tune on your labeled set, log result.model with each decision, and pin a versioned model such as jev-1.13.0 once thresholds are tuned; the jev-latest alias can move to a newer model.

6. Freeze a small labeled set before you trust it

Write the expected outcome first, then run the requests and compare. The rows below are synthetic tickets with the result our example rules require. They are not model results.

Synthetic ticketExpected queue (example rule)Needs details?
Please refund invoice INV-2291. I cancelled before the renewal date.billingNo
The export button returns error 500 since this morning's update.technicalNo
Do you offer a discount for 40 seats on the annual plan?salesNo
The payment page timed out, but my card was charged anyway.billing (rule: a charge that happened is billing)Yes: which charge
Can I move my current plan to annual billing, and will I be charged today?billing (rule: existing customers go to billing)No
I'd like to interview your team for a magazine article.otherNo
It doesn't work.Not decided: ask firstYes
Please send it to the other address.Not decided: ask firstYes (the needs-clarification recipe)

For each ticket, record the chosen queue, its confidence, the Noul value, and whether your code routed, asked or escalated. Count disagreements per queue and read them; they usually point to an unclear boundary sentence or a rule that belongs in code. Include the languages you actually receive. TypeSafe's models page (checked 2026-09-25) says English is the primary training language and asks you to test other languages on your own content.

Projects that use this pattern

These entries were source-checked from their READMEs on 2026-09-18. Jev Atlas has not run, benchmarked or security-audited them.

Sources

TypeSafe pages checked 2026-09-25. Models, limits and prices can change; check the provider before integrating.

Try it

Open support-routing in the lab · Open needs-clarification · Write better decision questions