Decision models (TypeSafe Jev)
Version, test, evaluate, and generate typed code for System-One decision prompts — noul, choice, and score question templates for TypeSafe Jev.
System-One decision models don't generate text. You ask them typed questions about a piece of context, and each answer comes back as a calibrated, typed value. The best-known example is TypeSafe's Jev (jev-latest, jev-preview, and pinned releases such as jev-1.13.0).
Jev still has to be prompted. The instructions, the answer options, and the shape of the context all decide what it returns. Sufleur treats them like any other prompt: you version them, review them as diffs, test them in the playground, grade them with evals, and install them into your code as typed bindings.
Anatomy of a decision prompt
A decision prompt is a set of question templates. Each question is rendered on its own, with its own inputs. Your code decides which questions to ask, how many times, under which keys, and against which state — then sends them to Jev in one request and reads the typed answers back.
| Name | Type | Description |
|---|---|---|
| question | one file each | Each question is an entrypoint file whose name is the question id. The file holds the instructions: Mustache text, or structured YAML (see below). |
| type + criteria | on the version | The output descriptor of each question, in order: noul, choice or score, with its criteria. String values in the criteria are Mustache templates. Shown as decision.yaml by the CLI. |
| optionCriteria | choice only | Optional. Lets callers add options at request time, each described by this template, e.g. one option per candidate in the data. |
Question ids must be identifiers (letters, digits, _), because they become keys in CEL, TypeScript, and Python.
The state — the content Jev reads — is not part of the prompt. It is whatever JSON value your code sends with the request: a string, an object, or a list.
Question types
Every question is one of three types. The type and its criteria decide the shape of the answer:
| Name | Type | Description |
|---|---|---|
| noul | { true?, false? } | A yes/no question answered as a probability. Criteria optionally describe what counts as true or false. Answer: { type, noul } with noul between 0 and 1. |
| choice | option → description | null | Pick one of 2–255 options. Answer: { type, choice, probabilities, confidence }, where choice is one of the option keys. |
| score | ordered list (2–10) | A position on an ordered rubric, lowest first. Answer: { type, score, legend, probabilities, confidence }, where score is the level index. |
A support-ticket and study-notes example:
questions:
department:
type: choice
criteria:
billing: Payments for {{{product}}}
technical: null
same:
type: choice
criteria:
same: { what: two names for the same thing, notFor: one being a kind of the other }
different: different things
about:
type: choice
criteria:
none: no listed concept fits
optionCriteria:
what: the wrong belief is about "{{{name}}}"
frustration:
type: score
criteria: [Calm, Frustrated, Very angry]Criteria entries can be plain strings, { what, notFor, examples } objects, or lists, mixed freely within one question. Option and level order is kept everywhere.
Templates and inputs
A question's instructions and its criteria are rendered with the same inputs. department above takes a product input, used only by its criteria. Option keys — the possible answers — are never templated.
Added options are rendered from optionCriteria with each option's own inputs. Asking about with options k01: { name: functor } and k02: { name: monad } sends the fixed none first, then one option per entry.
A question can't be rendered without its required inputs: generated code, the playground and evals reject a missing one instead of sending an empty value, and sufleur prompt render warns. Trailing whitespace at the end of a text question file (the newline editors add) is trimmed.
To point Jev at part of the state, write its backticked path yourself — Is `ticket.body` urgent? — or insert one from an input: `{{{article}}}`.
Structured YAML instructions
A question file can be TEXT (plain Mustache) or YAML: data whose string values are Mustache templates, parsed first and rendered second.
# files/frustration (format: YAML)
question: "How frustrated is the customer in `ticket.messages[0].text`?"
tier: "Customer tier: {{{tier}}}"
ticket: "{{ticket}}" # exactly one tag: the input value itself, kept structuredKeys are fixed and only string values are templates. Quote every value that contains a Mustache tag — {{ starts a flow mapping in bare YAML. Plain-text partials work as they do for LLM prompts.
Semver for decision prompts
Changes count toward the version bump like output-schema changes do:
- Major: removing a question, changing its type, adding or removing a fixed option, a new required input (including one used only by criteria), and adding or removing
optionCriteria. - Minor: adding a question, or rewording instructions or criteria.
The model config
A decision prompt's recommended model uses the Typesafe provider and a Jev model, with no sampling parameters:
sufleur version set-model-config @acme/ticket-triage@draft --provider typesafe --model jev-latestTo run the playground and evals, add a TypeSafe API key under your workspace's provider credentials. New decision prompts start on jev-latest.
Playground
The playground asks one question at a time: pick a question, fill in its inputs, add options for an open choice, and enter the state as JSON or text. Each run shows the answer — a gauge for noul questions, probability bars for choices, and the rubric position with its distribution for scores.
Evals
A decision prompt's eval lists the questions every dataset case asks, sent together in one request, and maps the state:
prompt:
inputMapping:
state: case.ticket # CEL over the case
questions:
- question: department
inputs: { product: case.product }
- question: same
key: n01 # ask a question more than once under distinct keys
inputs: { term: case.term, neighbour: case.neighbours[0] }
- question: same
key: n02
inputs: { term: case.term, neighbour: case.neighbours[1] }
- question: about
options: case.options # a map of option key → option inputs
assertions:
- kind: schema
- kind: expression
label: routes_to_right_team
expression: output.department.choice == case.expected_team
- kind: expression
label: closest_neighbour_wins
expression: output.n01.probabilities.same > output.n02.probabilities.sameoutput.<key> is each question's answer (the key defaults to the question id), typed by that question's answer schema, so CEL catches misspelt fields. Decision prompts can't be used as judges yet.
CLI
sufleur prompt create @acme/ticket-triage --kind system-one # seeds one noul question
sufleur version dump @acme/ticket-triage@draft --to ./triage # writes decision.yaml + files/
sufleur version set-decision-spec @acme/ticket-triage@draft --from-file ./triage/decision.yaml
sufleur prompt render ./triage --question department --vars '{"product":"Acme"}'
sufleur prompt render ./triage --question about --options '{"k01":{"name":"functor"}}'set-decision-spec is the only way to add or remove questions: it creates missing question files and turns dropped ones into partials. prompt render --question prints exactly what your code sends under questions.<key>. See the CLI reference.
Generated code
sufleur generate emits a typed getDecision (Node) or get_decision (Python) for every installed decision prompt:
batch()—ask(questionId, inputs, { key, options })renders a question and returns a typed handle; the key defaults to the question id, and a repeated key throws.items()lists the rendered questions to send.read(answers)validates each answer against its question;readAll(answers)fails unless every answer is valid.question(id, inputs, { options })andparseAnswer(id, raw)render or validate a single question.metadataholds theversionand the recommendedmodelConfig.
The generated code has no provider API in it. You build the request, choose the endpoint, and keep full control of keys and retries:
import { getDecision } from './generated/prompts'
const triage = getDecision('@acme/ticket-triage')
const batch = triage.batch()
const team = batch.ask('department', { product: 'Acme' })
const neighbours = Object.entries(state.neighbours).map(([key, n]) =>
batch.ask('same', { term: state.term, neighbour: n.term }, { key }),
)
const res = await fetch('https://api.typesafe.ai/v1/systemone', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.TYPESAFE_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: triage.metadata.modelConfig.model,
state,
questions: Object.fromEntries(batch.items().map((i) => [i.key, i.question])),
}),
}).then((r) => r.json())
const answers = batch.read(res.answers)
answers.getOrThrow(team).choice // 'billing' | 'technical'
for (const n of neighbours) {
const result = answers.get(n) // one bad answer doesn't discard the others
if (result.success) console.log(result.data.probabilities.same)
}The same questions work with any gateway that speaks the System-One format. OpenRouter's decisions endpoint (/api/alpha/decisions) takes the same body; it reports an oversized request as a 400 with max_tokens_exceeded, and usage as inputTokens / outputTokens / costUsd. Splitting a request that is too large is up to you: build two batches from halves of your data — the keys stay the same, so the answers merge back.