Classifier Models

A classifier model doesn't write text. It takes a state (the thing being judged) and a map of typed questions, and returns one typed answer per question with the probability distribution behind it. TypeSafe's Jev is the flagship example: a “System One” model that answers in 70–500 ms, charges only for input tokens, and can't return a malformed answer.

Jev can't sit behind a ModelAdapter, because it doesn't chat, stream or call tools. glove-classifier gives classifier models their own contract, ClassifierAdapter, and ships Jev, an LLM-backed classifier, a confidence-gated cascade, and tools for agents.

terminalbash
pnpm add glove-classifier

Question types

TypeAsksAnswer
noulIs this statement true?noul: probability of yes, 0–1
choiceWhich of these labels?choice, per-label probabilities, confidence
scoreWhere on this rubric?score (can land between levels), per-level probabilities, confidence

Names and wire shapes match TypeSafe's API. Ask atomic questions — each a judgement an expert could make in seconds — and combine them in code. Every question is judged in parallel in one call, so adding questions is nearly free.

Jev

triage.tstypescript
import { jev, noul, choice, score } from "glove-classifier";

const model = jev(); // TYPESAFE_API_KEY, model "jev-latest"

const { answers, model: served } = await model.classify({
  state: "Help! My payouts have been failing for 3 days.",
  questions: {
    is_urgent: noul("Does this convey urgency?"),
    department: choice("Which team should handle this?", {
      billing: "Payments, invoicing, refunds",
      technical: "Bugs, outages, integrations",
      sales: "Pricing, upgrades, new accounts",
    }),
    frustration: score("How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"]),
  },
});

answers.department.choice;     // "billing" | "technical" | "sales" — typed from the labels
answers.department.confidence; // 0.81
answers.frustration.score;     // 1.05
answers.is_urgent.noul;        // 0.95
served;                        // "jev-1.13.0" — the versioned model that answered

Options: apiKey (TYPESAFE_API_KEY), baseURL (TYPESAFE_BASE_URL), model (TYPESAFE_DEFAULT_MODEL, then jev-latest), timeout per attempt (10 s), maxRetries (2), backoffMs, headers and fetch. 408, 429 and 5xx responses (including 529 Overloaded) are retried with backoff, and Retry-After is honoured. Aborts raise glove-core's AbortError. Every other failure raises a ClassifierError with a code. Pin a versioned id such as jev-1.13.0 when you have tuned thresholds against it, because aliases move when a new version ships.

Other classifier models

Jev defined the POST /v1/systemone contract, and open typed-decision models now serve it on your own hardware. The presets are the same client (SystemOneClassifier) with each project's documented defaults. Start the server as its README describes, then point Glove at it:

models.tstypescript
import { kev, laya, von, rizzo, decider, systemOne } from "glove-classifier";

const local = laya();                                  // http://127.0.0.1:8000
const gpu = kev({ baseURL: "http://gpu-box:8009" });   // any address
const other = systemOne({ baseURL: "https://decisions.internal", model: "my-model", apiKey });
PresetModelDefault addressNotes
jev()TypeSafe Jev (hosted)api.typesafe.aiCalibrated. Needs TYPESAFE_API_KEY.
kev()Kev: Qwen3.5 + LoRA, 0.8B–27B127.0.0.1:8009KEV_API_KEY if the server sets one.
laya()Laya: ModernBERT / mmBERT, ~400M127.0.0.1:8000Runs on CPU. Score levels need descriptions.
von()Von: ModernBERT-large, 395Mlocalhost:8000
rizzo()Rizzo Flow: Spark-X2.5, 1.7B/4B127.0.0.1:8017At most 26 choice labels. Uncalibrated by default.
decider()Decider: Qwen-based, 0.8B–35B127.0.0.1:8000English only, 32k context.

Each preset reads <NAME>_BASE_URL and <NAME>_API_KEY. The client absorbs the ways these servers differ. It computes missing confidence from the probabilities, fills in a missing legend, usage or model, ignores extra fields, and checks each server's option limits locally. Pin the address and model in production, because the defaults follow each project's README as of September 2026.

Zero-shot label scorers. huggingfaceZeroShot() (NLI models over the Hugging Face Inference API), gliclass() and labelScorer() for your own model all map the three question types onto “score these labels”. They don't read instructions, so put the meaning in the labels.

On the labelled inbox (80 messages × 3 questions), hosted Jev scored 100% / 97.5% / 100% at 18 ms per message. Laya, self-hosted on 4 CPU cores with no GPU, scored 75% / 88.8% / 81.3% at 2.85 s per message. Its English checkpoint reads only 512 tokens of these long emails. A common setup is a self-hosted model as the primary of a cascade() with Jev or an LLM as the fallback.

Acting on confidence

route.tstypescript
import { gate } from "glove-classifier";

switch (gate(answers.department, { act: 0.8, review: 0.5 })) {
  case "act":      return route(answers.department.choice);
  case "review":   return confirmWithUser(answers.department.choice);
  case "escalate": return handToHuman();
}

answerConfidence() works for every answer type. A noul has no confidence field, so its certainty is its distance from a coin flip, |2p − 1|. Set thresholds per action to match the stakes.

Any LLM as a classifier

llm.tstypescript
import { createAdapter } from "glove-core/models/providers";
import { llmClassifier } from "glove-classifier";

const model = llmClassifier({
  model: createAdapter({ provider: "openai", model: "gpt-4.1-mini", stream: false }),
});

The LLM is asked for a probability distribution per question, returned as JSON, and the reply becomes the same typed answers. Replies that can't be parsed are retried. The probabilities are self-reported rather than calibrated, so treat them as a hint.

Cascade

cascade.tstypescript
import { cascade, jev, llmClassifier } from "glove-classifier";

const model = cascade({
  primary: jev(),
  fallback: llmClassifier({ model: reasoningModel }),
  threshold: 0.6, // or shouldEscalate(answer, id, question)
});

const result = await model.classify({ state, questions });
result.escalated; // ids the fallback answered

In an agent: mountClassifier

mountClassifier gives an agent classifier models it can use whenever a judgement is cheaper than reading. The host registers presets (named question sets) and sources (data streams such as an inbox or a ticket queue), and can add or remove them at any time.

agent.tstypescript
import { mountClassifier, jev, llmClassifier, noul, choice } from "glove-classifier";

const classifiers = mountClassifier(glove, {
  classifier: jev(),
  classifiers: { careful: llmClassifier({ model: reasoningModel }) },
  presets: {
    triage: {
      description: "Support triage",
      questions: {
        team: choice("Which team should handle this?", ["billing", "technical", "sales"]),
        urgent: noul("Does the sender need help today?"),
      },
    },
  },
  sources: {
    inbox: {
      description: "Unread support email",
      load: async () => (await mail.unread()).map((m) => ({ id: m.id, label: m.subject, state: m.body })),
    },
  },
});

classifiers.addSource("crm", { description: "Open CRM notes", load: loadNotes }); // any time
ToolWhat it does
glove_classifyJudges one state, with its own questions and/or a preset.
glove_classify_batchJudges many items the agent already holds. where keeps only the matches.
glove_classify_sourceJudges every item in a host source and returns only ids, labels and answers. The content never enters the agent's context.
glove_classify_catalogLists presets, sources and named classifiers.

A where condition is { question, choice?, min?, max? }, and a list of conditions must all hold. A noul matches when its yes-probability is at least min (default 0.5). A choice matches when choice is the chosen label, or, with min, when that label's probability is at least min. A score matches when it falls within min and max. For a single fixed judgement, use defineClassifierTool() instead, which asks your questions over the agent's input.

In code: REPLs and working environments

An agent that writes code can move data around without reading it, because only the program's return value enters its context. A classifier supplies the judgement that step needs, such as “which of these 400 emails ask for a refund?”, and the program returns three ids instead of 400 messages.

repl.tstypescript
import { JsSession, mountJs } from "glove-js";
import { classifierFns, jev } from "glove-classifier";

const session = JsSession.create();
session.registerAll(classifierFns(jev())); // classifier.classify / many / is / pick / rate
mountJs(glove, { session });

// what the agent writes:
const hits = classifier.many({
  items: emails.map(e => ({ id: e.id, label: e.subject, state: e.body })),
  questions: { refund: { type: "noul", instructions: "Does the sender ask for a refund?" } },
  where: { question: "refund", min: 0.7 },
});
hits.map(h => h.id)

REPL programs call host functions one at a time, so many runs a whole batch in parallel inside a single call. Programs get plain values: answers.refund is the yes-probability, answers.team is the label, and answers.urgency is the level. The full typed answers are under details, and a question can be a plain string. The same functions work in glove-python and glove-lisp, and in a working environment as env:classifier. That module ships a README and a classifier-triage skill:

env.tstypescript
import { classifierEnv } from "glove-classifier/env";

createWorkingEnvironment({ stdlib: [email(), classifierEnv(jev())] });

// a script:
import { glob, readFile } from 'env:fs';
import { many } from 'env:classifier';

export default async function () {
  const items = [];
  for (const path of await glob('/inbox/*.eml')) items.push({ id: path, state: (await readFile(path)).slice(0, 20000) });
  const hits = await many({ items, questions: { refund: { type: 'noul', instructions: 'Does the sender ask for a refund?' } }, where: { question: 'refund' } });
  return hits.map(h => h.id);
}

Browsers

withClassifier adds a judge operation to a glove-execution browser adapter. It observes the page, classifies what it sees, and returns only the answers. Questions like “is this a login wall?” or “did the order go through?” get answered without the DOM entering the agent's context.

browser.tstypescript
import { withClassifier } from "glove-classifier";

mountBrowser(glove, { adapter: withClassifier(stationBrowser({ client }), { classifier: jev() }) });

// in a browser workflow:
const { answers } = await browser.judge({
  sessionId,
  questions: { done: { type: "noul", instructions: "Did the order go through?" } },
});

Foundry transmissions

Every inbound event passes through its transmission's classify step and each playbook's predicates before any agent runs. With a classifier there, agents start only for the events that need them.

predicates/urgent.predicate.tstypescript
import { defineTransmissionPredicate } from "glove-foundry";
import { classifierPredicate, classifyInbound } from "glove-classifier/foundry";

export default defineTransmissionPredicate(classifierPredicate({
  classifier: jev(),
  questions: { urgent: noul("Does the sender need help today?") },
  where: { question: "urgent", min: 0.7 },   // playbook parameters may override: { min: 0.9 }
  state: (event: Ticket) => ({ subject: event.subject, body: event.body }),
}));

// in the transmission's inbound contract:
classify: classifyInbound({
  classifier: jev(),
  question: choice("What is this message?", ["refund", "bug", "other"]),
  events: { refund: refundRequested, bug: bugReported },
  fallback: generalInquiry,
  minConfidence: 0.6,
  state: (event: Ticket) => event.body,
}),

Measured

On a labelled 80-message inbox (examples/classifier-inbox), a gpt-4.1-mini agent that classified the inbox as a source held 3,705 tokens in context, against 24,455 when it read the inbox. Its refund F1 rose from 0.93 to 1.00, and the planted customer data reached it in 0 of 3 runs instead of 3 of 3. Jev judged 80 messages × 3 questions in 1.35 s for $0.0024, against 16.3 s and $0.028 for an LLM.

Context, speed and privacy

A classifier answer is small by construction: a probability, a label, or a level. When code or a tool asks the question, the content stays where it is and only the answer reaches the agent. This is the same boundary glove-egress enforces with assertions: decisions leave the sandbox, records do not. See Classifier models in Glove for the reasoning behind the design.

Bring your own classifier

Implement ClassifierAdapter, which has a name and a classify({ state, questions }, { signal }) method. It returns one answer per question id, with the same type as the question. answerFromDistribution(question, distribution) builds a well-formed answer from any probability distribution, and cascades, tools and gating then work unchanged.