> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Decisions

> Typed yes-or-no, pick-one and rating answers with probabilities, from a decision model instead of a chat model

`Decisions.ask` sends some state and one or more typed questions to a decision model and returns typed answers with probabilities. It never returns free text, so there is nothing to parse. Two providers sit behind one shape, TypeSafe Jev by default and OpenAI Decisions when the call carries images, sets `region: 'eu'` or sets `zeroRetention`, or when Jev is down. Available in tools, jobs, webhooks, triggers, processors and workflow code steps.

*Verified against lua-cli 3.48.0.*

```ts theme={null}
import { Decisions } from 'lua-cli';
```

## Quick example

```ts theme={null}
import { Decisions } from 'lua-cli';

const r = await Decisions.ask({
  state: { message: input.text, plan: user.plan },
  questions: {
    refund: Decisions.odds('Is the customer asking for a refund?'),
    team: Decisions.choice('Which team should handle this?', {
      billing: 'Charges, invoices, refunds',
      tech: 'Bugs, outages, login problems',
      sales: 'Upgrades, pricing questions',
    }),
    frustration: Decisions.score('How frustrated is the customer?', ['calm', 'annoyed', 'angry']),
  },
});

const { refund, team, frustration } = r.answers;
if (refund.type === 'odds') refund.odds;              // 0.93
if (team.type === 'choice') {
  team.choice;                                        // 'billing', typed as 'billing' | 'tech' | 'sales'
  team.confidence;                                    // 0.88
}
if (frustration.type === 'score') frustration.level;  // 'annoyed'
r.provider;                                           // 'jev'
```

Every answer can also be `{ type: 'refused' }`, so check `type` before you read its fields.

A single question returns its answer directly instead of a map.

```ts theme={null}
const refund = await Decisions.ask({ message: input.text }, Decisions.odds('Is the customer asking for a refund?'));
if (refund.type === 'odds' && refund.odds > 0.9) {
  return { action: 'block', response: 'A person will take this one.' };
}
```

## Question types

Each question type is named after the field its answer carries.

| Builder | Answer | Use it for |
| - | - | - |
| `Decisions.odds(instructions, criteria?)` | `{ type: 'odds', odds }` where `odds` is the probability from 0 to 1 that the statement holds | Is this a refund request? Is this message spam? |
| `Decisions.choice(instructions, options)` | `{ type: 'choice', choice, confidence, probabilities, uncertain }` | Which team, which intent, which category |
| `Decisions.score(instructions, levels)` | `{ type: 'score', score, level, confidence, probabilities, uncertain }` | How urgent, how frustrated, how severe |

`options` is an object of label to description, and the answer's `choice` is typed to those labels. `levels` is an ordered list from low to high, either strings or `{ label, description }`. `score` is a position along the levels and can fall between two of them; `level` is the nearest one. `probabilities` holds one entry per option or level and they sum to 1.

Any answer can also be `{ type: 'refused' }` when the provider declines to judge the content. Only OpenAI returns `refused`. Jev never does, but a call reaches OpenAI through images, `region`, `zeroRetention` or a fallback, so handle it on every call.

## Methods

### ask(request)

```ts theme={null}
Decisions.ask(request: DecisionRequest): Promise<DecisionResult>
```

<ParamField path="state" type="string | string[] | object" required>
  What the questions are about. Use an object with descriptive keys for anything beyond one piece of text, and refer to the keys in the instructions.
</ParamField>

<ParamField path="questions" type="Record<string, DecisionQuestion>" required>
  One to 64 questions built with `Decisions.odds`, `Decisions.choice` and `Decisions.score`. The answer keys match the question keys.
</ParamField>

<ParamField path="images" type="{ data: string, mimeType: string }[]">
  Up to 4 base64 images (`image/png`, `image/jpeg`, `image/webp`, `image/gif`), 5 MB each. Only OpenAI takes images, so they route the call to OpenAI.
</ParamField>

<ParamField path="provider" type="'auto' | 'jev' | 'openai'" default="auto">
  `auto` picks Jev unless the call carries images, sets `region: 'eu'` or sets `zeroRetention`. A pinned provider is never switched silently. It also overrides the image, region and zero-retention rules: `provider: 'jev'` with images fails with `decision_rejected`, and `provider: 'jev'` with `region: 'eu'` runs on Jev.
</ParamField>

<ParamField path="model" type="string">
  A model of the chosen provider, `jev-latest`, `jev-preview` or `gpt-6-luna`. Defaults to the provider's current model. A model of the other provider fails with `decision_invalid`. After a fallback, the other provider uses its own default model.
</ParamField>

<ParamField path="fallback" type="'jev' | 'openai' | 'none'" default="the other provider">
  The provider to try once when the first one times out or is unavailable. It defaults to the other provider, and to none when `provider` is pinned. `none` keeps the call on one provider. Calls with images, `region: 'eu'` or `zeroRetention` never fall back to Jev.
</ParamField>

<ParamField path="region" type="'eu'">
  Routes the call to OpenAI's EU endpoint.
</ParamField>

<ParamField path="zeroRetention" type="boolean" default="false">
  Routes the call to OpenAI. No retention setting is sent with the call.
</ParamField>

<ParamField path="minConfidence" type="number" default="0.7">
  Choice and score answers below this confidence come back with `uncertain: true` so your code can escalate instead of acting.
</ParamField>

<ParamField path="timeoutMs" type="number" default="3000">
  Per-call timeout in milliseconds. A value outside 100 to 15000 is clamped to that range, not rejected.
</ParamField>

<ParamField path="cache" type="{ ttlSeconds: number }">
  Caches identical calls per agent for up to a day. A longer `ttlSeconds` is capped at 86400. A cache hit costs nothing and carries `cached: true`.
</ParamField>

<ParamField path="logState" type="'summary' | 'full' | 'none'" default="summary">
  How much of `state` the log row keeps. `summary` records its size and top-level keys. `full` records the first 8,000 characters of the state as sent, with no redaction, and sets `truncated` when the state was longer. `none` records nothing.
</ParamField>

**Returns** a `DecisionResult`.

| Field | Meaning |
| - | - |
| `answers` | One typed answer per question key |
| `provider`, `model` | Who answered |
| `rule` | Why that provider was chosen: `default`, `pinned`, `images`, `region`, `zero_retention` or `fallback` |
| `fallback` | `{ from, reason }` when the first provider failed and the other one answered |
| `usage.inputTokens` | Tokens the provider counted |
| `durationMs`, `cached`, `decisionId` | Timing, cache hit, and the id that matches the log row |

### ask(state, question, options?)

```ts theme={null}
Decisions.ask(state: DecisionState, question: DecisionQuestion, options?: DecisionOptions): Promise<DecisionAnswer>
```

The single-question form. `options` takes every field above except `state`, `questions` and `images`, and the promise resolves to that one answer.

### odds(instructions, criteria?)

```ts theme={null}
Decisions.odds(instructions: string, criteria?: { yes?: string; no?: string }): OddsQuestion
```

`criteria` spells out what counts as yes and what counts as no when the instruction alone is ambiguous.

### choice(instructions, options)

```ts theme={null}
Decisions.choice(instructions: string, options: Record<string, string>): ChoiceQuestion
```

Two to 64 options. Each key is a label the answer can return and each value describes when it applies.

### score(instructions, levels)

```ts theme={null}
Decisions.score(instructions: string, levels: (string | { label: string; description: string })[]): ScoreQuestion
```

Two to 10 levels, ordered from low to high.

## Examples

### Route a message before the agent sees it

```ts theme={null}
import { Decisions, PreProcessor } from 'lua-cli';

export const router = new PreProcessor({
  name: 'route-by-team',
  description: 'Tags each message with the team that should handle it',
  execute: async (_user, messages) => {
    const text = messages.map((m) => (m.type === 'text' ? m.text : '')).join(' ');
    const team = await Decisions.ask(
      { message: text },
      Decisions.choice('Which team should handle this?', {
        billing: 'Charges, invoices, refunds',
        tech: 'Bugs, outages, login problems',
        sales: 'Upgrades, pricing questions',
      })
    );
    if (team.type !== 'choice' || team.uncertain) return { action: 'proceed' };
    return { action: 'proceed', metadata: { team: team.choice } };
  },
});
```

### Gate a webhook on a yes-or-no

```ts theme={null}
import { Decisions, LuaWebhook } from 'lua-cli';

export const reviewHook = new LuaWebhook({
  name: 'review-gate',
  description: 'Only forwards reviews that mention a defect',
  execute: async (event) => {
    const defect = await Decisions.ask(
      { review: event.body.text },
      Decisions.odds('Does the review describe a product defect?', {
        yes: 'Something broke, arrived damaged or stopped working',
        no: 'Taste, delivery speed, price or packaging preference',
      })
    );
    return { forward: defect.type === 'odds' && defect.odds >= 0.7 };
  },
});
```

### Judge an image

```ts theme={null}
const r = await Decisions.ask({
  state: 'Decide from the photo.',
  images: [{ data: base64Png, mimeType: 'image/png' }],
  questions: { damaged: Decisions.odds('Is the parcel damaged?') },
});
r.provider;   // 'openai'
r.rule;       // 'images'
const damaged = r.answers.damaged;
if (damaged.type === 'odds') damaged.odds;  // 0.97
```

OpenAI can refuse to judge an image, so handle `damaged.type === 'refused'` too.

## Errors

`Decisions.ask` and `Decisions.run` throw a `DecisionError` with these fields.

| Field | Meaning |
| - | - |
| `code` | One of the codes below |
| `provider` | The provider that failed, when one did |
| `upstreamStatus` | The HTTP status the provider answered with, when it answered with an error |

| Code | HTTP | Meaning |
| - | - | - |
| `decision_invalid` | 400 | A field is out of range or `model` belongs to the other provider; the message names it |
| `decision_insufficient_credits` | 402 | The org ran out of credits. The call that empties the balance still returns its answer, because billing runs after the answer. After that, decisions on this agent return 402 for 60 seconds. |
| `decision_not_found` | 404 | `Decisions.run` named a decision the agent does not have, or one with no current version |
| `decision_rejected` | 422 | The provider refused the request itself, for example Jev pinned with images, or state over the provider's limit |
| `decision_unavailable` | 503 | No provider could answer, after the fallback |
| `decision_disabled` | 503 | The chosen provider is switched off |
| `decision_timeout` | 504 | The provider did not answer within `timeoutMs` |

## Limits and cost

| Limit | Value |
| - | - |
| Questions per call | 1 to 64 |
| Options per choice | 2 to 64 |
| Levels per score | 2 to 10 |
| Instructions | 4,000 characters each |
| State | 120,000 characters |
| Images | 4 per call, 5 MB each |
| Timeout | 100 to 15,000 ms; values outside are clamped |
| Cache | Up to 86,400 seconds |

A decision costs 0.005 actions when Jev answers and 0.01 actions when OpenAI answers. A call that falls back is charged once, at the price of the provider that answered. Cached hits and failed calls cost nothing.

Every call writes one row to the agent's logs under the `decisions` source, visible with `lua logs --type decisions`. The row carries the provider, the routing rule, every answer with its probabilities, the tokens, `costUsd` and any fallback. `costUsd` is the provider's token-based cost estimate, not what you are charged.

## Related

* [`LuaDecision`](/reference/sdk/luadecision) declares a named, versioned decision on the agent and runs it with `Decisions.run`.
* [`AI`](/reference/sdk/ai) for free-text generation.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.