Typed decisions (judge)
Start here: ask & gate
Section titled “Start here: ask & gate”Most hosts do the same thing with a classifier: build a state, ask a few named questions, read a band, then act or ask a human. That is the whole layer. Two yes/no questions went from ~16 lines of nested question maps to five, and the wire request is byte-identical to the hand-written one.
import { createClassifier, judge, State, ask, gate, decide } from "toolnexus"
const c = createClassifier()const st = State("You are Donkey Kong. You want to win the game.", { message_received: msg })const qs = [ judge.noul("is_appropriate", "Does `message_received` contain insults, profanity or harmful topics?"), judge.noul("does_this_help", "Does `message_received` help Donkey Kong win the game?"),]const a = await ask(c, st, qs)a.is_appropriate.band // "yes" | "no" | "uncertain"a.does_this_help.value() // one number; a choice answer's option is .pick()
const out = await decide(c, st, qs, { rules: [{ question: "is_appropriate", at_least: 0.7, action: "fail" }], default: "", // "" = nothing fired -> a §10 input Request (ask a human) skipUncertain: true, // an uncertain rule is skipped; a missing answer still escalates})out.action; out.escalated; out.requestfrom toolnexus import ask, gate, noul, state, Rule, Policy
st = state("You are Donkey Kong. You want to win the game.", {"message_received": msg})qs = [ noul("is_appropriate", "Does message_received contain insults, profanity or harmful topics?"), noul("does_this_help", "Does message_received help Donkey Kong win the game?"),]a = await ask(classifier, st, qs)a["is_appropriate"].band # "yes" | "no" | "uncertain"a["does_this_help"].value()
out = await gate(classifier, st, qs, Policy( [Rule("is_appropriate", "fail", at_least=0.7)], default="", skip_uncertain=True))out.action, out.escalated, out.requestst := tn.State("You are Donkey Kong. You want to win the game.", map[string]any{"message_received": msg})qs := []tn.JudgeQuestion{ tn.Noul("is_appropriate", "Does `message_received` contain insults, profanity or harmful topics?"), tn.Noul("does_this_help", "Does `message_received` help Donkey Kong win the game?"),}a, err := tn.Ask(ctx, c, st, qs) // a["is_appropriate"].Band: yes | no | uncertainfmt.Println(a["does_this_help"].Value())
block := 0.7out, err := tn.Policy{ Rules: []tn.Rule{{Question: "is_appropriate", AtLeast: &block, Action: "fail"}}, Default: "", SkipUncertain: true,}.Gate(ctx, c, st, qs) // out.Action, out.Escalated, out.Requestimport static io.github.muthuishere.toolnexus.Judge.*;
var st = State.of("You are Donkey Kong. You want to win the game.", Map.of("message_received", msg));var qs = List.of( noul("is_appropriate", "Does message_received contain insults, profanity or harmful topics?"), noul("does_this_help", "Does message_received help Donkey Kong win the game?"));var a = ask(classifier, st, qs);a.get("is_appropriate").band(); // YES | NO | UNCERTAIN
Outcome out = new Policy(List.of(Rule.atLeast("is_appropriate", 0.7, "fail")), "", Bands.DEFAULT, true).decide(classifier, st, qs);out.action(); out.escalated(); out.request();var st = Judge.State("You are Donkey Kong. You want to win the game.", new Dictionary<string, object?> { ["message_received"] = msg });var qs = new[] { Judge.Noul("is_appropriate", "Does `message_received` contain insults, profanity or harmful topics?"), Judge.Noul("does_this_help", "Does `message_received` help Donkey Kong win the game?"),};var a = await Judge.AskAsync(classifier, st, qs);a["is_appropriate"].Band; // "yes" | "no" | "uncertain"a["does_this_help"].Value();
var out = await Judge.GateAsync(classifier, st, qs, new Policy { Rules = new[] { Rule.AtLeast("is_appropriate", 0.7, "fail") }, Default = "", SkipUncertain = true });// out.Action, out.Escalated, out.Requestimport Toolnexus.Judgealias Toolnexus.Judge.Policy
st = state("You are Donkey Kong. You want to win the game.", %{message_received: msg})qs = [ noul(:is_appropriate, "Does message_received contain insults, profanity or harmful topics?"), noul(:does_this_help, "Does message_received help Donkey Kong win the game?")]{:ok, a} = ask(classifier, st, qs)a["is_appropriate"].band #=> :yes | :no | :uncertainToolnexus.Judge.Answer.value(a["does_this_help"])
{:ok, out} = gate(classifier, st, qs, %Policy{ rules: [%{question: "is_appropriate", at_least: 0.7, action: "fail"}], default: "", skip_uncertain: true})(require '[toolnexus.judge :as j])
(def st (j/state "You are Donkey Kong. You want to win the game." {"message_received" msg}))(def qs [(j/noul :is_appropriate "Does message_received contain insults, profanity or harmful topics?") (j/noul :does_this_help "Does message_received help Donkey Kong win the game?")])
(def a (j/ask classifier st qs))(:band (get a "is_appropriate")) ;=> "yes" | "no" | "uncertain"(j/value (get a "does_this_help"))
(j/decide classifier st qs {:rules [{:question :is_appropriate :at-least 0.7 :action "fail"}] :default "" :skip-uncertain true});=> {:action … :escalated … :request …}use toolnexus::judge::{self, Policy, Rule};
let st = judge::state("You are Donkey Kong. You want to win the game.", json!({"message_received": msg}));let qs = [ judge::noul("is_appropriate", "Does `message_received` contain insults, profanity or harmful topics?"), judge::noul("does_this_help", "Does `message_received` help Donkey Kong win the game?"),];let a = judge::ask(&c, &st, &qs, None).await?; // a["is_appropriate"].band: yes | no | uncertainprintln!("{}", a["does_this_help"].value());
let out = Policy { rules: vec![Rule { question: "is_appropriate".into(), at_least: Some(0.7), action: "fail".into(), ..Default::default() }], default: String::new(), skip_uncertain: true, ..Default::default()}.gate(&c, &st, &qs).await?; // out.action, out.escalated, out.request- Bands default to 0.30 / 0.70 and are exclusive: exactly 0.70 is
uncertain. A choice or score answer carriessureinstead (a near-uniform choice is never sure). gatedeclines to decide. An uncertain, unsure or missing answer returnsneeds_inputwith a §10inputRequest — route it throughwaitForor your own queue. It never authorises.Taperecords live decisions by call name and replays them offline, so tests run on real answers.evaluateBatchasks the same questions of many states (state order, 16 in flight, fail-closed on the lowest failing index). Names per port are in each port’s README.
Two rules come from measuring it, not from taste:
- The role goes in the state. The wire has only
model,stateandquestions, so there is no system prompt to put it in.State(role, data)putsrolenext to your data. - Each question names the state field it judges. “Does the message contain…” is vague;
“Does
message_receivedcontain…” points at a field.
Live on TypeSafe jev-1.13.0, 2026-09-27, one good message and one insult
(is_appropriate asks about inappropriate language, so high means inappropriate):
| variant | good: inappropriate / helps | bad: inappropriate / helps |
|---|---|---|
| A state = JSON string with role | 0.02 / 0.76 | 0.57 / 0.02 |
| B state = object with role | 0.02 / 0.78 | 0.55 / 0.02 |
| C state = object, no role | 0.06 / 0.64 | 0.90 / 0.12 |
| D state = plain text message | 0.06 / 0.70 | 0.84 / 0.13 |
| E role inside the one question that needs it | 0.06 / 0.62 | 0.90 / 0.05 |
| F role in state + question names its field | 0.02 / 0.79 | 0.96 / 0.02 |
A string and an object state behave the same (A ≈ B). With the role in state and a vague question, the insult blurred to 0.55, right in the uncertain band. Naming the field (F) made it the sharpest row on both messages. Putting the role into the question instead (E) is rejected: it costs the question that needs the role. One sample per cell; Jev is near-deterministic, but these are readings, not averages. A full pipeline built on this is the test-scout scenario.
Batteries: judgments already wired into the loop
Section titled “Batteries: judgments already wired into the loop”| battery | what it decides | hook |
|---|---|---|
ToolGuardClassifier |
allow / ask / deny a tool call from a 0–3 risk rating; unsure always asks | beforeTool (ask = §10 approval suspension) |
ToolRelevanceClassifier |
which tools this turn needs; only a confident “no” drops one | beforeLLM (swaps tools) |
SkillRelevanceClassifier |
which skills are relevant | none; feed selected to the skill allowlist (never an empty list: empty means all) |
ToolResultFilterClassifier |
which paragraphs of a tool’s output matter | afterTool |
IsCompleteClassifier |
does the final answer complete the task | none |
AgentRouterClassifier |
which agent handles a task (walks your tree past 255) | none |
ContentGuardClassifier |
allow / review / block text on harmful and prompt_injection |
beforeLLM (block stops the run) |
ModelRouterClassifier |
opt-in per-query model from your described list | beforeLLM (overrides model for that turn) |
You must say what a classifier error means (onError: "open" | "closed", no default); the routers
fall back to your configured model or fallback instead. ToolGuard’s deny is a policy aid, not a
security control: keep allowlists in code in front of it.
import { createClassifier, createClient, ToolGuardClassifier, ModelRouterClassifier } from "toolnexus"
const c = createClassifier()const guard = new ToolGuardClassifier(c, { onError: "closed" })const router = new ModelRouterClassifier(c, [ { id: "small-fast", description: "Short factual answers, lookups and simple rewrites; cheapest." }, { id: "large-reasoning", description: "Multi-step reasoning and code changes across files; most expensive." },])
const client = createClient({ model: "large-reasoning", /* … */ hooks: { beforeTool: guard.asHook(myBeforeTool), // your hook still runs when the guard allows beforeLLM: router.asHook(), // sure pick routes this turn; otherwise "large-reasoning"}})from toolnexus import ToolGuardClassifier, ContentGuardClassifier
guard = ToolGuardClassifier(classifier, on_error="closed")v = await guard.check({"name": "delete_file", "arguments": {"path": "/tmp/x"}})v.action, v.reason, v.risk # "deny", "high risk", 2.7
hooks = {"before_tool": guard.as_hook(), "before_llm": ContentGuardClassifier(classifier, on_error="open").as_hook()}guard, _ := tn.NewToolGuard(c, tn.ToolGuardOptions{OnError: tn.OnErrorClosed})router := tn.NewModelRouter(c, []tn.ModelOption{ {ID: "small-fast", Description: "Short factual answers, lookups and simple rewrites; cheapest."}, {ID: "large-reasoning", Description: "Multi-step reasoning and code changes across files; most expensive."},}, tn.RouterOptions{})hooks := &tn.Hooks{BeforeTool: guard.AsHook(nil), BeforeLLM: router.AsHook(nil)}import io.github.muthuishere.toolnexus.*;import java.util.List;
ToolGuardClassifier guard = new ToolGuardClassifier(c, new ToolGuardClassifier.Options().onError(Batteries.OnError.CLOSED));ModelRouterClassifier router = new ModelRouterClassifier(c, List.of( new ModelRouterClassifier.Model("small-fast", "Short factual answers, lookups and simple rewrites; cheapest."), new ModelRouterClassifier.Model("large-reasoning", "Multi-step reasoning and code changes across files; most expensive.")));
LlmClient.Hooks hooks = new LlmClient.Hooks() .beforeTool(guard.asHook(myBeforeTool)) // your hook still runs when the guard allows .beforeLLM(router.asHook(null)); // sure pick routes this turnusing Toolnexus;
var guard = new ToolGuardClassifier(c, new ToolGuardOptions { OnError = OnError.Closed });var router = new ModelRouterClassifier(c, new[]{ new ModelOption("small-fast", "Short factual answers, lookups and simple rewrites; cheapest."), new ModelOption("large-reasoning", "Multi-step reasoning and code changes across files; most expensive."),});
var hooks = new LlmClient.Hooks{ BeforeTool = guard.AsHook(), // hooks are sync delegates; the battery blocks on the call BeforeLLM = router.AsHook(),};alias Toolnexus.Judge.{ToolGuard, ModelRouter}
{:ok, guard} = ToolGuard.new(c, on_error: :closed)
router = ModelRouter.new(c, [ %{id: "small-fast", description: "Short factual answers, lookups and simple rewrites; cheapest."}, %{id: "large-reasoning", description: "Multi-step reasoning and code changes across files; most expensive."} ])
hooks = %{before_tool: ToolGuard.as_hook(guard), before_llm: ModelRouter.as_hook(router)}(require '[toolnexus.batteries :as b])
(def guard (b/tool-guard c {:on-error :closed}))(def router (b/model-router c [{:id "small-fast" :description "Short factual answers, lookups and simple rewrites; cheapest."} {:id "large-reasoning" :description "Multi-step reasoning and code changes across files; most expensive."}]))
(def hooks {:before-tool (b/as-hook guard nil) :before-llm (b/as-hook router nil)})use toolnexus::judge::batteries::{ModelOption, ModelRouter, OnError, RouterOptions, ToolGuard, ToolGuardOptions};
let guard = ToolGuard::new(c.clone(), ToolGuardOptions { on_error: Some(OnError::Closed), ..Default::default() })?;let router = ModelRouter::new( c.clone(), vec![ ModelOption { id: "small-fast".into(), description: "Short factual answers, lookups and simple rewrites; cheapest.".into() }, ModelOption { id: "large-reasoning".into(), description: "Multi-step reasoning and code changes across files; most expensive.".into() }, ], RouterOptions::default(),);let hooks = Hooks { before_tool: Some(guard.as_hook(None)), before_llm: Some(router.as_hook(None)), ..Default::default() };The full per-port spelling of every battery is in the SPEC §8B idiom table.
Under the hood: the raw Classifier
Section titled “Under the hood: the raw Classifier”ask and gate are a thin layer over the map-in, typed-answers-out API below.
Run it first
Section titled “Run it first”Every tab below is an excerpt from a real file in the repo, and every one of these commands was
run to produce the output at the bottom of this page. Each one picks its backend from the
environment: TYPESAFE_API_KEY calls TypeSafe’s own API, OPENROUTER_API_KEY calls the same wire
through OpenRouter’s gateway, and with no key at all they replay one recorded decision through
the static backend — no network, no credential, no cost. The
snippets below show the TypeSafe branch, which is also the library’s default configuration.
| language | file | command |
|---|---|---|
| JavaScript | js/examples/judge.ts |
cd js && npm run build && npm run example:judge |
| Python | python/examples/judge.py |
cd python && uv run python examples/judge.py |
| Go | golang/examples/judge/main.go |
cd golang && go run ./examples/judge |
| Java | java/…/examples/Judge.java |
cd java && ./gradlew runJudge |
| C# | csharp/examples/Toolnexus.Examples/Judge.cs |
cd csharp/examples/Toolnexus.Examples && dotnet run -- judge |
| Elixir | elixir/examples/judge.exs |
cd elixir && mix run examples/judge.exs |
| Clojure | clojure/examples/src/examples/judge.cljc |
cd clojure/examples/clj && clojure -M -m examples.judge |
The state is one support ticket. The three questions ask three different kinds of thing about it, in one round trip.
The questions
Section titled “The questions”The map keys (wants_money_back, department, urgency) are yours, and they are addressing,
not content — they are never transmitted to the model, so a key may be a tool, skill or agent name
verbatim.
import { createClassifier, noul, choice, score } from "toolnexus"
const QUESTIONS = { wants_money_back: noul("Is the customer asking for money to be returned?"), department: choice("Which desk should own this ticket?", { billing: "own it here when the problem is money that moved: a duplicate charge, a wrong invoice, a refund owed", shipping: "own it here when the problem is a physical parcel: a late delivery, a package damaged in transit", technical: "own it here when the problem is the product itself: a login that fails, a feature that errors", }), urgency: score("How fast does this ticket need a human?", [ "the customer is working normally and is waiting on an answer", "the customer is inconvenienced and will chase if nobody replies today", "the customer is blocked from working right now and every hour costs them", ]),}
const judge = createClassifier({ baseUrl: "https://api.typesafe.ai/v1", // TypeSafe's own API — also the library default model: "jev-latest", apiKeyEnv: "TYPESAFE_API_KEY", // the NAME of an env var, never the value onMetric: (ev) => ev.event === "classifier.warning" && console.log("warning:", ev.warning),})
const d = await judge.evaluate(TICKET, QUESTIONS)
const want = d.noul("wants_money_back")const dept = d.choice("department")const urg = d.score("urgency")from toolnexus import ChoiceQuestion, NoulQuestion, ScoreQuestion, create_classifier
QUESTIONS = { "wants_money_back": NoulQuestion("Is the customer asking for money to be returned?"), "department": ChoiceQuestion( "Which desk should own this ticket?", { "billing": "own it here when the problem is money that moved: a duplicate charge, a wrong invoice, a refund owed", "shipping": "own it here when the problem is a physical parcel: a late delivery, a package damaged in transit", "technical": "own it here when the problem is the product itself: a login that fails, a feature that errors", }, ), "urgency": ScoreQuestion( "How fast does this ticket need a human?", [ "the customer is working normally and is waiting on an answer", "the customer is inconvenienced and will chase if nobody replies today", "the customer is blocked from working right now and every hour costs them", ], ),}
judge = create_classifier( base_url="https://api.typesafe.ai/v1", # TypeSafe's own API — also the library default model="jev-latest", api_key_env="TYPESAFE_API_KEY", # the NAME of an env var, never the value)
d = await judge.evaluate(TICKET, QUESTIONS)
want = d.noul("wants_money_back")dept = d.choice("department")urg = d.score("urgency")var questions = map[string]toolnexus.Question{ "wants_money_back": toolnexus.NoulQuestion{ Instructions: "Is the customer asking for money to be returned?", }, "department": toolnexus.ChoiceQuestion{ Instructions: "Which desk should own this ticket?", Criteria: map[string]string{ "billing": "own it here when the problem is money that moved: a duplicate charge, a wrong invoice, a refund owed", "shipping": "own it here when the problem is a physical parcel: a late delivery, a package damaged in transit", "technical": "own it here when the problem is the product itself: a login that fails, a feature that errors", }, }, "urgency": toolnexus.ScoreQuestion{ Instructions: "How fast does this ticket need a human?", Criteria: []string{ "the customer is working normally and is waiting on an answer", "the customer is inconvenienced and will chase if nobody replies today", "the customer is blocked from working right now and every hour costs them", }, },}
judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ BaseURL: "https://api.typesafe.ai/v1", // TypeSafe's own API — also the library default Model: "jev-latest", APIKeyEnv: "TYPESAFE_API_KEY", // the NAME of an env var, never the value})if err != nil { log.Fatal(err)}
d, err := judge.Evaluate(context.Background(), ticket, questions)if err != nil { log.Fatal(err)}
want, err := d.Noul("wants_money_back")dept, err := d.Choice("department")urg, err := d.Score("urgency")private static final Map<String, Classifier.Question> QUESTIONS = Map.of( "wants_money_back", new Classifier.NoulQuestion("Is the customer asking for money to be returned?"), "department", new Classifier.ChoiceQuestion("Which desk should own this ticket?", Map.of( "billing", "own it here when the problem is money that moved: a duplicate charge, a wrong invoice, a refund owed", "shipping", "own it here when the problem is a physical parcel: a late delivery, a package damaged in transit", "technical", "own it here when the problem is the product itself: a login that fails, a feature that errors")), "urgency", new Classifier.ScoreQuestion("How fast does this ticket need a human?", List.of( "the customer is working normally and is waiting on an answer", "the customer is inconvenienced and will chase if nobody replies today", "the customer is blocked from working right now and every hour costs them")));
Classifier judge = Classifier.create(new Classifier.Options() .baseUrl("https://api.typesafe.ai/v1") // TypeSafe's own API — also the library default .model("jev-latest") .apiKeyEnv("TYPESAFE_API_KEY")); // the NAME of an env var, never the value
Classifier.Decision d = judge.evaluate(TICKET, QUESTIONS);
Classifier.NoulAnswer want = d.noul("wants_money_back");Classifier.ChoiceAnswer dept = d.choice("department");Classifier.ScoreAnswer urg = d.score("urgency");private static readonly IReadOnlyDictionary<string, Question> Questions = new Dictionary<string, Question>{ ["wants_money_back"] = new NoulQuestion { Instructions = "Is the customer asking for money to be returned?", }, ["department"] = new ChoiceQuestion { Instructions = "Which desk should own this ticket?", Criteria = new Dictionary<string, string> { ["billing"] = "own it here when the problem is money that moved: a duplicate charge, a wrong invoice, a refund owed", ["shipping"] = "own it here when the problem is a physical parcel: a late delivery, a package damaged in transit", ["technical"] = "own it here when the problem is the product itself: a login that fails, a feature that errors", }, }, ["urgency"] = new ScoreQuestion { Instructions = "How fast does this ticket need a human?", Criteria = new[] { "the customer is working normally and is waiting on an answer", "the customer is inconvenienced and will chase if nobody replies today", "the customer is blocked from working right now and every hour costs them", }, },};
var judge = Classifier.Create(new ClassifierOptions{ BaseUrl = "https://api.typesafe.ai/v1", // TypeSafe's own API — also the library default Model = "jev-latest", ApiKeyEnv = "TYPESAFE_API_KEY", // the NAME of an env var, never the value});
var d = await judge.EvaluateAsync(Ticket, Questions);
var want = d.Noul("wants_money_back");var dept = d.Choice("department");var urg = d.Score("urgency");alias Toolnexus.Classifieralias Toolnexus.Classifier.{Choice, Decision, Noul, Score}
questions = %{ "wants_money_back" => %Noul{instructions: "Is the customer asking for money to be returned?"}, "department" => %Choice{ instructions: "Which desk should own this ticket?", criteria: %{ "billing" => "own it here when the problem is money that moved: a duplicate charge, a wrong invoice, a refund owed", "shipping" => "own it here when the problem is a physical parcel: a late delivery, a package damaged in transit", "technical" => "own it here when the problem is the product itself: a login that fails, a feature that errors" } }, "urgency" => %Score{ instructions: "How fast does this ticket need a human?", criteria: [ "the customer is working normally and is waiting on an answer", "the customer is inconvenienced and will chase if nobody replies today", "the customer is blocked from working right now and every hour costs them" ] }}
{:ok, judge} = Classifier.create( base_url: "https://api.typesafe.ai/v1", model: "jev-latest", api_key_env: "TYPESAFE_API_KEY" )
{:ok, d} = Classifier.evaluate(judge, ticket, questions)
{:ok, want} = Decision.noul(d, "wants_money_back"){:ok, dept} = Decision.choice(d, "department"){:ok, urg} = Decision.score(d, "urgency")(require '[toolnexus.classifier :as jev])
(def questions {"wants_money_back" (jev/noul-question "Is the customer asking for money to be returned?")
"department" (jev/choice-question "Which desk should own this ticket?" {"billing" "own it here when the problem is money that moved: a duplicate charge, a wrong invoice, a refund owed" "shipping" "own it here when the problem is a physical parcel: a late delivery, a package damaged in transit" "technical" "own it here when the problem is the product itself: a login that fails, a feature that errors"})
"urgency" (jev/score-question "How fast does this ticket need a human?" ["the customer is working normally and is waiting on an answer" "the customer is inconvenienced and will chase if nobody replies today" "the customer is blocked from working right now and every hour costs them"])})
(def judge (jev/create-classifier {:base-url "https://api.typesafe.ai/v1" :model "jev-latest" :api-key-env "TYPESAFE_API_KEY"}))
(let [d (jev/evaluate judge ticket questions) want (jev/noul d "wants_money_back") dept (jev/choice d "department") urg (jev/score d "urgency")] ...)use toolnexus::classifier::{ChoiceQuestion, NoulQuestion, ScoreQuestion};use toolnexus::{ClassifierOptions, Question, Questions, create_classifier};
let questions: Questions = [ ("wants_money_back".to_string(), Question::Noul(NoulQuestion { instructions: "Is the customer asking for money to be returned?".into(), ..Default::default() })), ("department".to_string(), Question::Choice(ChoiceQuestion { instructions: "Which desk should own this ticket?".into(), criteria: [ ("billing", "own it here when the problem is money that moved: a duplicate charge, a wrong invoice, a refund owed"), ("shipping", "own it here when the problem is a physical parcel: a late delivery, a package damaged in transit"), ("technical", "own it here when the problem is the product itself: a login that fails, a feature that errors"), ] .map(|(k, v)| (k.to_string(), v.to_string())) .into(), })), ("urgency".to_string(), Question::Score(ScoreQuestion { instructions: "How fast does this ticket need a human?".into(), criteria: vec![ "the customer is working normally and is waiting on an answer".into(), "the customer is inconvenienced and will chase if nobody replies today".into(), "the customer is blocked from working right now and every hour costs them".into(), ], })),].into();
let judge = create_classifier(ClassifierOptions { base_url: "https://api.typesafe.ai/v1".into(), // TypeSafe's own API — also the library default model: "jev-latest".into(), api_key_env: "TYPESAFE_API_KEY".into(), // the NAME of an env var, never the value ..Default::default()})?;
let d = judge.evaluate(json!(ticket), &questions).await?;
let want = d.noul("wants_money_back")?;let dept = d.choice("department")?;let urg = d.score("urgency")?;Reading an answer as the wrong type is an error, not a surprise: the typed accessors fail
loudly (an exception, an {:error, _}, an err) rather than handing you a zero.
What it prints
Section titled “What it prints”One live TypeSafe decision, through the JavaScript port:
backend: systemone via api.typesafe.ai (live)
model answering: jev-1.13.0wants_money_back: 0.99 (a noul carries NO confidence — the number IS the answer)department: billing p={"technical":0,"billing":1,"shipping":0} confidence=1urgency: 0.48 of 0..2 p={"0":0.52,"1":0.48,"2":0} level 0: the customer is working normally and is waiting on an answer (a score MAY fall between levels)
calibrated: truenearUniform(department): false
usage: 516 in / 72 out / cost: not reported by this backendWhich key you set changes one line, and one field
Section titled “Which key you set changes one line, and one field”TYPESAFE_API_KEY goes to TypeSafe’s own API; OPENROUTER_API_KEY goes to the same wire through
OpenRouter’s gateway. They are equivalent in latency — 339 ms / 449 ms (p50 / p95) against
351 ms / 400 ms, warm and interleaved, which is a tie — so pick on dependencies, not speed: a
gateway in the path versus a first-party key. The one functional difference is usage.cost, a
gateway field that TypeSafe does not return; that is why the line above says
cost: not reported by this backend rather than $0, and why a cost-based budget only works
through the gateway. The
backends page has the full comparison.
With no key at all the first line reads
backend: static (recorded — set TYPESAFE_API_KEY or OPENROUTER_API_KEY to go live), the model
answering is the recorded typesafe/jev-1.13-20260917, and the usage line carries the gateway’s
$0.000021672 — the same decision, replayed.
Three things in that output are worth a second look.
urgencyis0.48. Not 0 and not 1: the model is genuinely split between “waiting on an answer” and “will chase today”, and the number says so.p={"0":0.52,"1":0.48}says it again, with the disagreement visible instead of averaged away. It was0.49on the recording and0.48live — the backend is non-deterministic, which is why onlystaticmay be asserted against.calibrated: true— these probabilities came from a calibrated backend, so a threshold tuned here transfers. Anllm-style backend reportsfalse, and your thresholds do not carry over.nearUniform(department): false— the model had something to rank on.truewould be the symptom of an encoding that told it nothing.
The encoding obligation
Section titled “The encoding obligation”That is why every option in the example above carries a real sentence, why all three use the same template (“own it here when the problem is X: a, b, c”), and why the host does the counting and hands over the conclusion rather than shipping raw numbers.
The encoding rules has the four rules with the measurement behind
each, the degenerate-criteria warning the library emits when you get this wrong, and the precise
limits of nearUniform, confidence and calibrated as health signals.
Recipe: the same questions on OpenAI’s Decisions API
Section titled “Recipe: the same questions on OpenAI’s Decisions API”The key is read from OPENAI_API_KEY at call time and is sent only to api.openai.com over
https. Decisions from this backend report calibrated: false, so a threshold tuned on Jev does
not carry over — the snippets below check it before comparing a number. A refusal comes back as an
error, never as a “no”. These snippets call the live API, so they need the key; the details are on
the backends page.
import { createClassifier, noul, choice, score } from "toolnexus"
const judge = createClassifier({ backend: "openai" }) // OPENAI_API_KEY, gpt-6-luna
const d = await judge.evaluate("The package arrived with a broken screen.", { damaged: noul("Does the customer report a damaged item?"), team: choice("Which team should handle this?", { shipping: "lost, late or damaged parcels", billing: "refunds and payments", }), upset: score("How upset is the customer?", ["calm", "annoyed", "furious"]),})
console.log(d.noul("damaged"), d.choice("team"), d.score("upset"))if (!d.calibrated) console.log("calibrated: false — re-tune thresholds for this backend")from toolnexus import ChoiceQuestion, NoulQuestion, ScoreQuestion, create_classifier
judge = create_classifier(backend="openai") # OPENAI_API_KEY, gpt-6-luna
d = await judge.evaluate( "The package arrived with a broken screen.", { "damaged": NoulQuestion("Does the customer report a damaged item?"), "team": ChoiceQuestion( "Which team should handle this?", {"shipping": "lost, late or damaged parcels", "billing": "refunds and payments"}, ), "upset": ScoreQuestion("How upset is the customer?", ["calm", "annoyed", "furious"]), },)
print(d.noul("damaged"), d.choice("team"), d.score("upset"))if not d.calibrated: print("calibrated: false — re-tune thresholds for this backend")judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ Backend: toolnexus.BackendOpenAI, // OPENAI_API_KEY, gpt-6-luna})if err != nil { log.Fatal(err)}
d, err := judge.Evaluate(context.Background(), "The package arrived with a broken screen.", map[string]toolnexus.Question{ "damaged": toolnexus.NoulQuestion{Instructions: "Does the customer report a damaged item?"}, "team": toolnexus.ChoiceQuestion{ Instructions: "Which team should handle this?", Criteria: map[string]string{ "shipping": "lost, late or damaged parcels", "billing": "refunds and payments", }, }, "upset": toolnexus.ScoreQuestion{ Instructions: "How upset is the customer?", Criteria: []string{"calm", "annoyed", "furious"}, }, })if err != nil { log.Fatal(err)}
damaged, _ := d.Noul("damaged")team, _ := d.Choice("team")upset, _ := d.Score("upset")fmt.Println(damaged, team, upset, "calibrated:", d.Calibrated)Classifier judge = Classifier.create(new Classifier.Options() .backend(Classifier.BACKEND_OPENAI)); // OPENAI_API_KEY, gpt-6-luna
Classifier.Decision d = judge.evaluate("The package arrived with a broken screen.", Map.of( "damaged", new Classifier.NoulQuestion("Does the customer report a damaged item?"), "team", new Classifier.ChoiceQuestion("Which team should handle this?", Map.of( "shipping", "lost, late or damaged parcels", "billing", "refunds and payments")), "upset", new Classifier.ScoreQuestion("How upset is the customer?", List.of("calm", "annoyed", "furious"))));
System.out.println(d.noul("damaged") + " " + d.choice("team") + " " + d.score("upset"));if (!d.calibrated()) System.out.println("calibrated: false — re-tune thresholds for this backend");var judge = Classifier.Create(new ClassifierOptions{ Backend = ClassifierBackend.OpenAI, // OPENAI_API_KEY, gpt-6-luna});
var d = await judge.EvaluateAsync("The package arrived with a broken screen.", new Dictionary<string, Question>{ ["damaged"] = new NoulQuestion { Instructions = "Does the customer report a damaged item?" }, ["team"] = new ChoiceQuestion { Instructions = "Which team should handle this?", Criteria = new Dictionary<string, string> { ["shipping"] = "lost, late or damaged parcels", ["billing"] = "refunds and payments", }, }, ["upset"] = new ScoreQuestion { Instructions = "How upset is the customer?", Criteria = new[] { "calm", "annoyed", "furious" }, },});
Console.WriteLine($"{d.Noul("damaged")} {d.Choice("team")} {d.Score("upset")}");if (!d.Calibrated) Console.WriteLine("calibrated: false — re-tune thresholds for this backend");alias Toolnexus.Classifieralias Toolnexus.Classifier.{Choice, Decision, Noul, Score}
{:ok, judge} = Classifier.create(backend: "openai") # OPENAI_API_KEY, gpt-6-luna
{:ok, d} = Classifier.evaluate(judge, "The package arrived with a broken screen.", %{ "damaged" => %Noul{instructions: "Does the customer report a damaged item?"}, "team" => %Choice{ instructions: "Which team should handle this?", criteria: %{"shipping" => "lost, late or damaged parcels", "billing" => "refunds and payments"} }, "upset" => %Score{instructions: "How upset is the customer?", criteria: ["calm", "annoyed", "furious"]} })
{:ok, damaged} = Decision.noul(d, "damaged"){:ok, team} = Decision.choice(d, "team"){:ok, upset} = Decision.score(d, "upset")IO.inspect({damaged, team, upset, d.calibrated})(require '[toolnexus.classifier :as jev])
(def judge (jev/create-classifier {:backend "openai"})) ; OPENAI_API_KEY, gpt-6-luna
(let [d (jev/evaluate judge "The package arrived with a broken screen." {"damaged" (jev/noul-question "Does the customer report a damaged item?") "team" (jev/choice-question "Which team should handle this?" {"shipping" "lost, late or damaged parcels" "billing" "refunds and payments"}) "upset" (jev/score-question "How upset is the customer?" ["calm" "annoyed" "furious"])})] (println (jev/noul d "damaged") (jev/choice d "team") (jev/score d "upset") "calibrated:" (:calibrated d)))use toolnexus::classifier::{ChoiceQuestion, ClassifierBackend, NoulQuestion, ScoreQuestion};use toolnexus::{ClassifierOptions, Question, Questions, create_classifier};
let judge = create_classifier(ClassifierOptions { backend: ClassifierBackend::OpenAi, // OPENAI_API_KEY, gpt-6-luna ..Default::default()})?;
let questions: Questions = [ ("damaged".to_string(), Question::Noul(NoulQuestion { instructions: "Does the customer report a damaged item?".into(), ..Default::default() })), ("team".to_string(), Question::Choice(ChoiceQuestion { instructions: "Which team should handle this?".into(), criteria: [("shipping", "lost, late or damaged parcels"), ("billing", "refunds and payments")] .map(|(k, v)| (k.to_string(), v.to_string())) .into(), })), ("upset".to_string(), Question::Score(ScoreQuestion { instructions: "How upset is the customer?".into(), criteria: vec!["calm".into(), "annoyed".into(), "furious".into()], })),].into();
let d = judge.evaluate(json!("The package arrived with a broken screen."), &questions).await?;println!("{:?} {:?} {:?} calibrated: {}", d.noul("damaged")?, d.choice("team")?, d.score("upset")?, d.calibrated);A choice here needs at least two options — the Decisions wire takes 2–255, and a one-option
choice fails before any request is made, as does a raw question type such as box.
Where to go next
Section titled “Where to go next”| page | what it answers |
|---|---|
| Typed decisions | what a Classifier is, the three question types, the Decision shape |
| The encoding rules | what to write inside a question, with the measurements |
| Backends & configuration | systemone, llm, custom, static, and every option |
| Measured on a live backend | latency, cost, non-determinism, and the one control that can fail |