# 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](https://typesafe.ai)'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.

> [!NOTE] Decision prompts are a prompt kind
>
> When you create a prompt you pick its **kind**: an **LLM prompt** (chat templates, the default)
> or a **decision model** prompt (`SYSTEM_ONE`). The kind is fixed at creation. Everything else —
> workspaces, semver, drafts, publishing, collections, visibility — works the same.

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

```yaml
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.

> [!WARNING] Use triple braces for values
>
> `{{name}}` HTML-escapes the value, and the libraries don't agree on how: a `"` becomes
> `&quot;` in TypeScript and Python but `&#34;` in the CLI, and only TypeScript escapes `/`. Use
> `{{{name}}}` to insert values as they are, so every runtime sends the same text.

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.

```yaml
# 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.

> [!NOTE] Minor is about compatibility, not behaviour
>
> Wording decides what Jev answers. Shortening one set of criteria has halved the relations a
> real prompt found in a book. Re-run the eval on every text change, even when the bump is minor.

## The model config

A decision prompt's recommended model uses the **Typesafe** provider and a Jev model, with no sampling parameters:

```bash
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](/docs/evals) lists the questions **every dataset case asks**, sent together in one request, and maps the state:

```yaml
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

```bash
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](/docs/cli#decision-prompts).

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

#### TypeScript

```ts
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)
}
```

#### Python

```python
import os, httpx
from generated.prompts import get_decision

triage = get_decision("@acme/ticket-triage")
batch = triage.batch()

team = batch.ask("department", {"product": "Acme"})
neighbours = [
    batch.ask("same", {"term": state["term"], "neighbour": n["term"]}, key=key)
    for key, n in state["neighbours"].items()
]

res = httpx.post(
    "https://api.typesafe.ai/v1/systemone",
    headers={"Authorization": f"Bearer {os.environ['TYPESAFE_API_KEY']}"},
    json={
        "model": triage.metadata["modelConfig"]["model"],
        "state": state,
        "questions": {key: question for key, _, question in batch.items()},
    },
).json()

answers = batch.read(res["answers"])
print(answers.get_or_throw(team).choice)  # "billing" | "technical"
```

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.
