---
title: "Decisions"
description: "One call that labels a ticket, and a person when the answering decider is not allowed to decide alone."
icon: "Scale"
source: "docs/spec/unified-theory.md"
---

A decision labels one piece of work: which queue a ticket belongs in, and whether it needs a person today. The decider is the model that answers. Autonomy is that decider's certificate, not a property of the decision.

<Callout title="The one rule">
  Name a decider and an `otherwise`. `otherwise` is a Gate, or `"abstain"`. Only that decider's
  certificate in `oke-decisions.lock.json` can return `auto`.
</Callout>

## Quick start

<Steps>

<Step>
### Declare the decider and the questions

`otherwise` is a real gate. The people who hold it answer when the model is not allowed to.

```typescript title="src/core/ai.ts"
import { ai, gate } from "okengine";

export const ops = gate.policy("ops", ({ operator }) => operator.id !== null);

export const jev = ai.decider("jev", {
  provider: "openrouter",
  model: "typesafe/jev-1.13-20260917",
});

export const triage = ai.decision("triage", {
  decider: jev,
  otherwise: ops,
  ask: {
    team: ai.choice("Which team owns this ticket?", {
      billing: "Billing",
      technical: "Technical",
    }),
    urgent: ai.boolean("Does this need a person today?"),
  },
});
```

Every choice also accepts `none_of_these`. That answer is never `auto`. Question ids `meta` and `$` are reserved.

</Step>

<Step>
### Call it from a durable Flow

A gate parks the run. An HTTP trigger cannot park. Emit a signal and decide in a consumer.

```typescript title="src/flows/support.ts"
import { flow, on, signal } from "okengine";
import { triage } from "../core/ai.ts";

export const routed = signal.once("support.routed");

export const route = on(
  signal.once("support.opened"),
  flow("support.route", {
    durable: true,
    do: async (input, fx) => {
      const result = await fx.decide(triage, input);
      await fx.emit(routed, result);
      return result;
    },
  }),
);
```

`durable: true` is required when `otherwise` is a gate. `"abstain"` can run without it.

</Step>

<Step>
### Read `how`, `by`, and `why`

`auto` is the decider's value. Anything else takes `otherwise`.

```json
{
  "team": "technical",
  "$": {
    "team": { "how": "auto", "by": "jev", "p": 0.91, "raw": { "technical": 0.91 } },
    "urgent": { "how": "auto", "by": "jev", "p": 0.54, "raw": { "noul": 0.54 } }
  }
}
```

</Step>

</Steps>

## Result

| Field     | When                                          |
| --------- | --------------------------------------------- |
| `how`     | `auto`, `reviewed`, or `abstained`.           |
| `by`      | The decider that answered.                    |
| `why`     | Only when the question is not `auto`.         |
| `p`       | Calibrated confidence.                        |
| `raw`     | The distribution. A boolean keeps `{ noul }`. |
| `audited` | The auto value was sampled for a label.       |

`why` is one of `uncertain`, `refused`, `none_of_these`, `uncertified`, `drift`, or `outage`.

`otherwise: "abstain"` returns `null` and does not park. `how` is `abstained`. A Gate parks until someone who passes it submits every open question. Console on port 6533 resolves that park. There is no timeout.

A refusal is not a label. The value is `null`, `why` is `refused`, and the review row reads `refused by <decider>`.

## Options

| Option      | Required | Meaning                                                         |
| ----------- | -------- | --------------------------------------------------------------- |
| `decider`   | yes      | An `ai.decider` handle. There is no default.                    |
| `otherwise` | yes      | A Gate, or `"abstain"`.                                         |
| `backup`    | no       | Tried only after an outage: open breaker, HTTP 5xx, or timeout. |
| `ask`       | yes      | `ai.choice`, `ai.score`, or `ai.boolean`.                       |
| `autonomy`  | no       | `{ maxError, audit }`. `audit` is required when this is set.    |
| `locale`    | no       | `(input) => string`. Each locale is its own certificate slice.  |
| `evals`     | no       | Seed JSONL for `oke decide certify`.                            |
| `in`        | no       | Fields kept when labels are exported.                           |

`shadow` is reserved. Using it fails with `shadow is planned`.

`backup` does not run on HTTP 4xx. That is `DecisionRequestError`, and the breaker stays closed. State larger than `maxContext` throws `DecisionInputTooLarge` before any call. It does not take `otherwise`.

Auto requires the answering decider's own certificate: same echoed model, unexpired, same question hash, and a calibrated score at or above the threshold. A backup that already has a certificate does not need another one.

## Certificates

`oke decide certify <name> --deciders a,b` calls each decider on the seed file and writes lockfile version 2. One certificate per decider. The table prints accuracy, ECE, certified coverage, p50, p95, and cost.

```json
{
  "version": 2,
  "decisions": {
    "triage": {
      "deciders": {
        "jev": { "model": "typesafe/jev-1.13-20260917", "pinned": true, "questions": {} }
      }
    }
  }
}
```

A version 1 file fails with: oke-decisions.lock.json is not version 2. Run `oke decide certify` to recertify.

OpenRouter certificates bind to the dated slug. If the catalog lists a dated id, certify requires that id. OpenAI binds to the echoed alias, stores `pinned: false`, and sets `expiresAt` to certify time plus 30 days. Certify prints that the model is unpinned. After expiry, `why` is `uncertified`.

`oke decide promote <name>` installs the operator candidate for one decision. `oke decide labels <name> --export` writes reviewed labels as seed JSONL. The file contains production data. `oke decide models` lists catalog id, canonical slug, context, and input modalities.

`t.ai.decide(decision, answers)` scripts probabilities, or `{ refusal: "…" }`. The refusal string is not stored.

## Drivers

`drivers.decide` defaults to `{ test: "mock" }`. Unset `dev` and `prod` call the decider host. Pin `mock` when a local boot must stay off the network.

## Troubleshooting

<Accordions>

<Accordion title="otherwise is required">
  `ai.decision("triage"): otherwise is required`. Pass a Gate or `"abstain"`.
</Accordion>

<Accordion title="shadow is planned">
  `ai.decision("triage"): shadow is planned`. Remove `shadow`.
</Accordion>

<Accordion title="review cannot run in HTTP flow">
  `ai.decision("triage") review cannot run in HTTP flow "…" — a park answers 204.` Emit a signal and
  decide in a consumer. A gate also needs `durable: true`: `must set durable: true to review`.
</Accordion>

<Accordion title='fx.decide: secret "OPENROUTER_API_KEY" is not configured'>
  The decider's secret is not in the vault. OpenAI's preset reads `OPENAI_API_KEY`.
</Accordion>

<Accordion title="why is uncertified">
  The lock is missing, the echoed model differs, the question hash moved, the locale has no slice,
  or an alias certificate expired. Run `oke decide certify`.
</Accordion>

<Accordion title="state is N tokens; max context is M">
  `fx.decide: decider "…" state is N tokens; max context is M`. Shrink the input. This throws. It
  does not take `otherwise`.
</Accordion>

</Accordions>

## Learn more

- [Deciders](/docs/elements/ai/deciders) — presets, protocols, and pinning
- [Gate](/docs/elements/gate) — the policy on `otherwise`
- [fx](/docs/reference/fx) — `fx.decide`
- [CLI](/docs/reference/cli) — `oke decide certify`, `promote`, `labels`, `models`
- [Configuration](/docs/reference/configuration) — `drivers.decide`

## Next

<Cards>
  <Card
    title="Deciders"
    description="The model that answers a decision."
    href="/docs/elements/ai/deciders"
  />
  <Card title="Agents" description="Tools, steps, and approval." href="/docs/elements/ai/agents" />
  <Card title="fx" description="What a Flow is allowed to touch." href="/docs/reference/fx" />
</Cards>
