docs
Concepts

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.

NameTypeDescription
questionone file eachEach question is an entrypoint file whose name is the question id. The file holds the instructions: Mustache text, or structured YAML (see below).
type + criteriaon the versionThe 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.
optionCriteriachoice onlyOptional. 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:

NameTypeDescription
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.
choiceoption → description | nullPick one of 2–255 options. Answer: { type, choice, probabilities, confidence }, where choice is one of the option keys.
scoreordered 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:

text
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.

text
# 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 structured

Keys 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:

text
sufleur version set-model-config @acme/ticket-triage@draft --provider typesafe --model jev-latest

To 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:

text
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.same

output.<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

text
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 }) and parseAnswer(id, raw) render or validate a single question.
  • metadata holds the version and the recommended modelConfig.

The generated code has no provider API in it. You build the request, choose the endpoint, and keep full control of keys and retries:

text
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.