JevDK: a developer's guide

Design and test the questions your app asks a decision model — on your Mac, against hosted Jev, before they're in code

This guide is for app developers who want a small model to make decisions inside their app: route a message, pick a team, spot personal information, check an answer. It explains, in plain English, how that differs from a chatbot, and how to use JevDK to get questions you can trust.

You don't need to know anything about how models work inside. You do need some patience: a decision model is closer to building a small test suite than to writing a prompt.

New to decision models altogether? Start with Decision models (Jev) in your app: what they are if you know chatbots, and links to TypeSafe's and others' documentation.

Get JevDK

JevDK is open source (Apache 2.0) at github.com/ancientcomputing/jevdk, built only on the LocalLM Lab SDK's public API. It needs an Apple-silicon Mac with macOS 27.

First run: Examples → Customer support loads four questions and sample messages. For answers on this Mac, pick a model with Models (toolbar); Qwen3-4B-4bit (2.3 GB) is the tested starting point. To compare with a hosted decider, turn one on in Backends: the Featherless demo needs no key; Featherless and OpenRouter (TypeSafe) keys are kept in your Keychain. Hosted deciders receive the inputs you send them, and the status bar says where they go.

↑ Top

What a decision model does

A chatbot takes a prompt and writes a reply. You read the reply and judge it.

A decision model ("decider") takes one input, such as a customer message, and answers a few fixed questions about it. Each answer is one of the options you allowed, plus how sure the model is. It writes no text.

InputQuestionAnswer
"Nothing loads since this morning, my whole team is stuck."Which team should handle this? (billing / technical / account)technical, 100%
Is the customer blocked from using the product right now?Yes, 96%
Does the customer explicitly ask for money back?No, 97%

Your app then acts on the answers in ordinary code: send it to the technical team, mark it urgent, don't open a refund. Nothing is generated, so it's fast (about 150 ms for four questions on a small local model) and the answers are always something your code can switch on.

The SDK call looks like this:

let d = try await lab.decide(route: "support", state: .text(message), questions: [
          .choice("team", "Which team should handle this customer message?", criteria: [
              "billing": "payments, invoices and refunds",
              "technical": "bugs, outages and product errors",
              "account": "profile, login and subscription changes",
          ]),
          .noul("blocked", "Is the customer blocked from using the product right now?"),
          .noul("refund", "Does the customer explicitly ask for money back?"),
      ])
      if let team = d.choice("team"), team.confidence > 0.8 { route(to: team.choice) }

Why this is harder than a chatbot

With a chatbot you judge each reply as you read it. With a decider, nobody reads the answers: your code acts on them, thousands of times, unseen. So you can't judge a question by trying it once. You have to know how often it's right, on inputs like the ones your app will see. That means collecting examples, writing down the right answers, and measuring. JevDK is the tool for that.

↑ Top

When a decider is a good fit

Good fits: questions a careful person could answer from the input alone, quickly and consistently.

Poor fits:

When the decision needs your product's knowledge

Say the decider routes bug reports to teams. To do that it has to know something about your software: which parts exist, and who owns what. It only knows what's in the input and the questions. There's no system prompt to put background in: hosted deciders don't have one, and the SDK leaves it out so local and hosted behave the same. So the knowledge has to reach it one of these ways, best first:

  1. Ask what the report is about, and keep “who owns what” in your code. The decider is good at recognising which part of the product a report describes; it shouldn't need to know your org chart. Ask about components, then look the team up in an ordinary table:
    let d = try await lab.decide(route: "triage", state: .text(report), questions: [
        .choice("component", "Which part of the product does this bug report describe?", criteria: [
            "checkout": "cart, payment form, Stripe errors, order confirmation",
            "sync":     "files not syncing, conflicts, the desktop sync client",
            "auth":     "sign-in, password reset, SSO, two-factor codes",
            "editor":   "the document editor, formatting, comments, undo",
        ]),
    ])
    let team = owners[d.choice("component")!.choice]     // your table: component → team
    When teams reorganise, you change the table, not the questions, so there's nothing to re-test or recalibrate. The product knowledge lives in the option descriptions: write them in the words users actually use (“Stripe errors”, “files not syncing”).
  2. Describe each team by what it owns. If teams map neatly onto areas, make the teams the options: "payments": "billing, invoices, the Checkout module, Stripe integration". Fine while each description stays short. A local decider takes up to 26 options; hosted Jev up to 255.
  3. Send the knowledge with the input. When a line per option isn't enough, send structured context alongside the report, as JSON:
    struct Ticket: Encodable { let report: String; let screen: String; let stackTraceModules: [String] }
    let d = try await lab.decide(route: "triage", state: try .encoding(ticket), questions: [...])
    The best context is what your app already knows as data: the screen the user was on, the app version, the modules in a stack trace. It's precise and free. Keep it short: a local decider reads the input once for all the questions in a call, but a long input is still slower, and hosted services cap the size (6,000 characters on the Featherless demo).
  4. Look up the relevant part first. For a big knowledge base (an architecture doc, a wiki), don't send all of it. Have your app find the few relevant pieces (match file paths or error codes, or search your docs) and send only those. If finding them is itself a judgement, let your chat model do that step and give the decider its short summary.

Don't put product knowledge in JevDK's System instructions: only the local decider sees them, and calibration is tied to their exact words. And don't ask questions whose answer isn't in the input or the options (“Which team owns the sync engine?”); like current facts, the decider can only judge what it's given.

Test it in JevDK as usual: real reports in Batch, the knowledge in the option descriptions or pasted into each input the way your app will send it, the right component or team marked. Compare the approaches by their ✓s on your own reports.

↑ Top

The five things that decide your results

  1. The model. Different models are good at different questions. You find out by measuring, not by reading a leaderboard.
  2. How each question is worded. The single biggest lever. One word can flip answers.
  3. The system instructions (local models only): the text that frames every question. Also changes answers; JevDK lets you edit it, then your app uses the same text.
  4. Your answer set: example inputs with the right answers written down. Without it you are guessing.
  5. Calibration: making the model's "how sure" numbers mean what they say. Optional, and only once the answers themselves are good.

The workflow below works through them in that order.

↑ Top

The workflow in JevDK

Step 1. Write your questions

On the left, Add question and pick a type:

Give each question a short name (what your code reads, e.g. refund) and write the question the model reads.

Wording rules that held up in testing:

Step 2. Try single inputs

In Single input, type a realistic input and press Ask (⌘↩). Every question is answered independently (no question sees another's answer). For each, you get a bar per option, the chosen answer and how sure it was.

What to look at:

Use this step to shape questions. Use the next steps to trust them.

JevDK's single-input view: the message 'I see two charges for my Pro plan on this month's statement. Please refund one of them.' answered by a local Qwen3-4B decider and Featherless's hosted classifier; both say team billing 100% and urgent No 100%, with a bar for every option.
Single input: each question's answer from each decider, with a bar for every option. Show prompt / Show request show exactly what each decider received.

Step 3. Build your answer set

Collect 50 to 150 real inputs like the ones your app will see. Real is better than invented: real messages are messier, and the mess is where models go wrong. Include:

Paste them into Batch, one per line, and Ask all. If you already have them in a spreadsheet, save it as CSV with an input column and one column per question holding the correct answer (blank where you haven't decided), and use File → Import Answers (CSV)… instead: the inputs and your marks load together. Yes/no answers can be Yes/No, true/false or 1/0; a choice is its key; a score is its level number or text. Then, under each cell, use mark… to record the correct answer. This is the slow part, and the most valuable: from now on every run is scored against your answers.

Tips:

Step 4. Run the batch and read the grid

Each row is an input, each column a question, each cell an answer. With answers marked, every backend's line shows ✓ or ✗, its answer and how sure it was. Tinted cells mean backends disagree. Hide the questions panel (sidebar button, top left) to see all the columns.

Read it in this order:

  1. Which columns have the ✗s? A question that's wrong often needs rewording, not a different model.
  2. Are the misses confident? "✗ No 100%" is worse than "✗ No 55%": the second can be caught with a threshold, the first can't.
  3. Do several models miss the same row? Then check your mark and the wording first. In our test, every model said a customer whose mobile app crashed (desktop fine) was "blocked". That says more about the question than the models.
JevDK's batch grid with correct answers marked. The local Qwen3-4B decider (1) answers 'Is the customer blocked?' No at 100% for two messages where the customer is blocked; both are marked wrong and outlined with the letter A. The hosted decider (2) answers Yes at 100% on both.
The batch grid with answers marked. ① local Qwen3 4B, ② Featherless. A the local decider is wrong twice, and 100% sure: the worst kind of miss (step 4, question 2).

Step 5. Fix the questions, then run again

Change one thing, Ask all again, compare. Typical fixes:

Stop when the remaining misses are ones you can live with, or can catch (step 8).

Step 6. Compare models

The model is the answer to "which of these works best on my questions", and only your answer set can tell you. JevDK doesn't keep a leaderboard on purpose.

  1. Models (toolbar): download a few candidates. Small dense instruct models suit deciding; mixture-of-experts models are flagged because their probabilities were unstable in testing.
  2. Backends (toolbar): optionally add a hosted decider (Featherless, or TypeSafe on OpenRouter) as a reference point.
  3. Run the batch with model A, then Results CSV (Batch toolbar) → append. Switch to model B, run, append.
  4. Open the CSV in Numbers or Excel. A pivot table of correct by model and question is per-question accuracy; question_ms and decision_ms are speed.

Weigh up:

Why it matters
Accuracy per questionThe question your app relies on most matters most.
Confidence when wrongLow confidence on misses means a threshold can catch them.
SpeedAdded to every request your app routes.
SizeA local decider sits next to your chat model in memory.
Local vs hostedLocal is private, free and offline; hosted can be more accurate and better calibrated.

Step 7. Calibrate the model you picked (local only)

A small model often says "100%" even when wrong; another might say "60%" when it's nearly always right. Calibration adjusts the numbers so "80% sure" means right about 80% of the time. It never changes which answer wins.

You need it when your app acts on the confidence (thresholds, second opinions). You don't if you only use the top answer.

  1. Have at least 30 marked answers per question type you use (yes/no, choice, score).
  2. Calibrate… → Fit calibration. JevDK finds one adjustment ("temperature") per question type. Above 1 means the model was over-confident and gets softened; below 1, under-confident and gets sharpened.
  3. Read the before → after table. Lower is better on both measures (see Reading the numbers).
  4. Turn on Show local results calibrated to see what your app will see.
  5. If you change the model or the system instructions later, JevDK flags the calibration until you redo it.

Use the calibration in your app

A calibration is just three numbers: the Temperature column of the Calibrate window's Fitted table, X for yes/no questions, Y for choice and Z for score (a kind you have no questions of stays at 1.0, “no change”). Your app doesn't work them out again; it only needs to be given them.

Where to find them: in JevDK, after marking answers in Batch (steps 3–4), click Calibrate… above the grid and Fit calibration. The window that opens looks like this:

JevDK's Calibrate window. In the Fitted table's Temperature column, X marks the yes/no (Noul) value, 9.11, and Y the Choice value, 0.25. Below, the Copy code snippet shows the same numbers in place: DecisionCalibration(noulTemperature: 9.11 marked X, choiceTemperature: 0.25 marked Y, scoreTemperature: 1.0 marked Z).
JevDK's Calibrate window, after Fit calibration (two parts of it, stacked). Top: the Fitted table, where you read the numbers in the Temperature column. Bottom: the code under For your app, which Copy code copies, with the same numbers in place. X yes/no questions, Y choice, Z score (this example has no score questions, so Z stays 1.0: no change).

Two ways to give them to your app:

Afterwards, your app's answers say they're calibrated (fidelity == .tokenScored(calibrated: true)). The numbers fit the model version, system instructions and kinds of question you tested, so fit them again and re-export when any of those changes; openjev.tuningMismatch(for:) tells your app when the model on the device isn't the one you calibrated. Hosted deciders don't need this: TypeSafe's Jev comes calibrated.

What calibration can't do: fix wrong answers. In our test, a confidently wrong answer stayed wrong; it just became less confidently wrong. Get the questions right first (steps 4 and 5).

The same grid after calibration: the local decider's two wrong answers (A) now read No 91% and No 93% and are still wrong; its right answers on the same question (B) now read 94-95%.
After calibrating on these marks: A the mistakes drop to 91–93% but stay wrong; B the right answers soften too, to 94–95%. Calibration made the numbers honest, but this question still needs rewording (step 5).

Step 8. Ship it

Use the same questions, model, system instructions and calibration you tested. File → Export Questions… writes them into one file (support.decisions.json); add it to your app and load it:

let set = try DecisionQuestionSet(contentsOf: Bundle.main.url(forResource: "support.decisions", withExtension: "json")!)
      let openjev = OpenJevDecisionProvider(mlx: mlx, tunedWith: set)   // the tested wrapper and calibration
      try lab.models.register(decision: openjev)
      lab.models.route(decision: "support", to: ModelID("openjev:mlx-community/Qwen3-4B-4bit")!)

      let d = try await lab.decide(route: "support", state: .text(message), questions: set.questions)

Your app now asks exactly the questions you tested; to change one, change it in JevDK, test, and export again. openjev.tuningMismatch(for:) tells you if the model on the device isn't the one you tested (e.g. a newer revision).

Keep testing in your app's CI. With the exported answer set, lab.evaluate runs every example and scores it, so a model, SDK or question change that breaks answers fails a test:

let answers = try DecisionAnswerSet(csv: String(contentsOf: answersURL, encoding: .utf8))
      let result = try await lab.evaluate(route: "support", questions: set.questions, answerSet: answers)
      #expect(result.score("refund")!.accuracy >= 0.95)

Not a Swift app, or several apps? Serve the tested setup over HTTP instead: File → Export Server Config… writes a config for jev-serve, which answers hosted Jev's API (OpenRouter's and Featherless's) on this Mac. jev-serve comes with the JevDK download: choose JevDK → Install jev-serve Command… once to put it on your PATH. Code that already calls hosted Jev, in any language, switches by changing its base URL:

jev-serve --config jev-serve.json
curl -s http://127.0.0.1:8746/v1/classifier -H "Authorization: Bearer $TOKEN" -d @request.json

Patterns that work:

JevDK's files

The File menu groups the five kinds of file JevDK reads and writes:

File menuThe fileWhat's in itUse it to
Workspace: Open…, Save Workspace (⌘S), Save Workspace As…Customer support.jevdk.json, JevDK's own formatEverything: questions, system instructions, the input and batch list, your marks, the calibrationPick your work back up, or hand it to a colleague. JevDK also keeps your last workspace between launches.
For your app: Export Questions… (⌘E)customer-support.decisions.json, the SDK's DecisionQuestionSetThe questions exactly as tested, plus the model, version, system instructions and calibration. No inputs or marks.Bundle it in your app (step 8).
For your app: Export Server Config…jev-serve.jsonWhere jev-serve listens, an optional token, and the models it serves, each with its version, system instructions and calibrationServe the tested setup over hosted Jev's HTTP API on your Mac. Exporting again adds or replaces this question set's entry.
Test data: Import / Export Answers (CSV)…customer-support.answers.csvYour inputs and the right answers: an input column, then one column per questionEdit test cases in a spreadsheet, share them, run them in your app's tests with lab.evaluate.
Test data: Append Run to Results CSV…jevdk-results.csv, grows with every runEvery answer from every backend in a run, with the model, timing and whether it was rightCompare models and wordings over time (step 6).

While you work, Save Workspace. When the questions are ready, Export Questions, and Export Answers for your app's tests. Open… also reads an exported .decisions.json, so a file you shipped can come back into JevDK.

↑ Top

A worked example: customer support

Sixteen made-up customer messages, four questions (team, blocked, refund, angry), correct answers marked, run on three deciders. Real results, 2026-10-03:

teamblockedrefundangryTotal
Featherless Qwen3.8-27B (hosted)15/1615/1616/1615/1661/64
Featherless Qwen3.5-4B (hosted)16/1615/1615/1615/1661/64
Qwen3-4B on this Mac15/1612/1616/1615/1658/64

What a developer learns from this:

Sixteen messages are enough to show the method, not to choose a model. Use 50 or more.

↑ Top

Reading the numbers

↑ Top

Common mistakes

↑ Top

Glossary

↑ Top

Contact

Trying JevDK? Tell us what worked and what didn't: neuron@thisbrain.ai or our Discord.

↑ Top