# Decisions

> Ask a decision model for typed probabilities, choices and scores without running a full turn.

A decision is a small, fast request with a typed answer: the probability a condition is true, one value from a fixed set, or a position on a scale. Zidane calls the provider's Decisions endpoint and normalizes the answer, so hooks can make cheap routing and policy calls.

| Provider | Endpoint | Default model | Credentials |
| - | - | - | - |
| `openrouter()` | `POST /api/alpha/decisions` | `typesafe/jev-1.13` | OpenRouter API key |
| `openai()` | `POST /v1/decisions` | `gpt-6-luna` | Platform API key (`sk-…`). The endpoint rejects Codex OAuth tokens. |

## Ask from a hook

Every hook context has `ctx.decide`, bound to the active run. Aborting the run cancels the decision, and `decision:*` hooks carry the run id. Outside hooks, call `agent.decide(...)`.

```ts
const agent = createAgent({
  provider: [anthropic(), openrouter()],
  hooks: {
    'tool:gate': async (ctx) => {
      if (ctx.name !== 'shell')
        return
      const r = await ctx.decide({
        input: String(ctx.input.command),
        questions: {
          destructive: { type: 'predicate', instructions: 'Does this command delete or overwrite data?' },
        },
      })
      if (r.ok && r.answers.destructive.type === 'predicate' && r.answers.destructive.probability > 0.8) {
        ctx.block = true
        ctx.reason = 'Looks destructive'
      }
    },
  },
})
```

## Questions and answers

Each answer comes back under its question's key. Choice values are typed from the keys: `answers.team.choice` is `'payments' | 'frontend'`.

```ts
const r = await agent.decide({
  input: { ticket: 'Checkout shows a blank page after Pay' },
  questions: {
    isBug: { type: 'predicate', instructions: 'Is the customer reporting a defect?', criteria: { true: 'Broken behavior', false: 'A question or request' } },
    team: { type: 'choice', instructions: 'Which team owns this?', choices: { payments: 'Checkout and billing', frontend: 'Rendering and layout' } },
    urgency: { type: 'score', instructions: 'How urgent is it?', levels: ['Next release', 'This week', { label: 'Now', description: 'Blocking revenue' }] },
  },
})
```

| Type | Answer |
| - | - |
| `predicate` | `{ type: 'predicate', probability }`, between 0 and 1 |
| `choice` | `{ type: 'choice', choice, probabilities?, confidence? }` |
| `score` | `{ type: 'score', score, probabilities?, confidence? }`. `score` is the probability-weighted mean of level indices (from 0). A score question takes 2 to 10 levels (`MAX_SCORE_LEVELS`): more fails as `invalid` before any request. |
| any | `{ type: 'refusal', message? }` when the model declines |

`input` is a string, a JSON object or prompt parts. OpenAI accepts inline base64 images. OpenRouter takes text and JSON only, and returns `unsupported` for images.

## Failures never throw

`decide` returns `{ ok: false, reason, message }` on failure, so your hook picks fail-open or fail-closed.

| `reason` | When |
| - | - |
| `unavailable` | Decisions off, no capable provider, missing or rejected credentials, or unknown model. Known up front: nothing is sent. After a 404, zidane skips that model for the agent's lifetime. |
| `unsupported` | The input cannot go to this provider |
| `timeout` | Over `timeoutMs` (default 5000) |
| `aborted` | The run or your `signal` was cancelled |
| `auth` | HTTP 401 / 403 |
| `rate_limited` / `quota` | HTTP 429 or 529 / HTTP 402 |
| `invalid` | Bad questions, a `timeoutMs` of 0 or less or above 2147483647, or a malformed response |
| `error` | Anything else |

| Call | Does |
| - | - |
| `ctx.decide.available`, `agent.decide.available` | Returns synchronously whether there is a provider, credentials that allow decisions, and a model not known to be gone |
| `agent.decide(req, { throw: true })` | Throws a `DecisionError` instead: `reason` as above, `outcome` holds the failure |

Providers throw `DecisionHttpError`, `DecisionUnavailableError`, `DecisionUnsupportedError`, `DecisionInvalidResponseError` or `DecisionTimeoutError`. zidane maps them to these reasons.

## Configuration

By default the agent uses the first provider in `provider` that supports decisions and has credentials for them, with its default model. If none has credentials, it uses the first that supports decisions, which reports why it is unavailable. Override with `decisions`:

```ts
createAgent({
  provider: anthropic(),
  decisions: { provider: openrouter(), model: 'typesafe/jev-1.13', timeoutMs: 2000 },
})

createAgent({ provider: openrouter(), decisions: false }) // every decision returns `unavailable`
```

### In the apps

In the terminal app, open Settings › Agent › Models. In the desktop app, open Settings › Agent › Helper models.

| Setting | Does |
| - | - |
| Decisions | Turns decisions on. Off by default, because the provider bills each call. |
| Decision model | Picks from the models your signed-in providers can use |
| Test | Sends one small decision with the credentials of the host that runs the sessions. Shows the latency or the failure reason (for example `auth: invalid API key`). |

## Automatic session titles

Auto-update session titles (`autoTitle`, next to the Decisions setting) needs decisions on. It titles a session after its first successful run. After every two new prompts, the decision model asks whether the title still fits, and rewrites it if not. The host runs this at run end, so it works the same in the TUI, the desktop app and server sessions.

| Rule | Detail |
| - | - |
| Length | 1 to 5 words while the setting is on, also for Generate title |
| Typed titles | Never replaced. Renames set `metadata.titleSource: 'user'`. Generated titles set `'auto'`. |
| Re-enable | Generate the title once from session details to hand it back to auto-titling |
| Titles without `titleSource` | Treated as yours. A title the desktop app stamped from the first prompt counts as untitled. |
| Main session | Keeps its name |

Auto-titling does not show errors in the chat. The Title line in session details shows the source and the last attempt:

```text
[auto] [checked 5m ago, still fits]
[auto] [check failed 2m ago (unavailable: …)]
```

## Time estimates

Estimate time remaining (`runEstimates`, under Auto-update session titles, desktop and web apps only) needs decisions on too. It asks how long each running session and subagent has left, and shows a countdown in the sidebar and on subagent headers. See [time estimates](https://zidane.si/docs/run-estimates.md).

## Observability

| Signal | Detail |
| - | - |
| `decision:start` / `decision:end` | Fire on every call. `decision:end` carries `ok`, `reason`, `durationMs`, `usage`, `answers`. Usage is not added to run totals. |
| Tracing | One `decide <model>` span per decision |
| Metrics | `gen_ai.decision.duration` and `gen_ai.decisions`. The cost goes into `gen_ai.cost_usd`. |
| Logging | Warns on failures other than `unavailable` |
| Durable runtime (`restateRuntime`) | During a run, each decision is a `decision <run>:<turn>:decide:0` step, failures included, so replays take the same branch. `agent.decide` outside a run is not journaled. |

`decision:*` contexts have no `decide`, so a decision cannot trigger another.

## Reference

- [`zidane/types`](https://zidane.si/docs/reference/types.md)
- [`zidane`](https://zidane.si/docs/reference/index.md)
- [`zidane/providers/openrouter`](https://zidane.si/docs/reference/providers-openrouter.md)
- [`zidane/providers/openai`](https://zidane.si/docs/reference/providers-openai.md)
