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

# Recommendations

> The Decision API's decision step: candidate actions with their value and cost, business rules, a do-nothing baseline, exploration with a logged propensity, and one recommendation with a templated why, written back to the lead's ledger

A [Decision API](https://levers9.mintlify.site/lab/decision-api.md) request may carry a **decision block**: the actions you could take next for a
lead, what a success through each is worth and what each costs, the rules you must respect, and the objective.
The response then carries a **recommendation** beside the answers: the action to take, every candidate ranked
with its score, whether a person should review it, a why written from the numbers, and the event to append to the
lead's ledger.

Two parts make it. The **network** scores each candidate: its chance of success if you take that action now. The
**decision step** turns those chances into the pick. The decision step has no learned weights: it is plain code
in the Decision API, run after the network, and everything it uses comes from the request. Changing a price, a
rule or the objective changes the request, never the model.

> [!WARNING]
>
> **Use your own chances for decisions that matter.** The chances come from your request (`chances`, for example
> from your own simulator or estimator), or from one `success_if_<action>` question per candidate asked of the
> served model. The model that reads journeys answers four of them from a head trained on logged decisions
> (`send_offer`, `call_again`, `hand_to_rep`, `no_action`; marked `"trained": true`): its chances are right on
> average and nearly the same for every kind of lead, so they show the step working and do not yet tell you which
> lead an action moves. A candidate with any other key is asked with no training behind it and marked
> `"trained": false`. [The tutorial](https://levers9.mintlify.site/lab/journey-decisions.md) has a full request and response.

## The request

The canonical decision block, sent to `POST /decide` beside `state` (and `questions`, which become optional):

```json theme={null}
"decision": {
  "question": "What should we do next for this lead?",
  "candidates": {
    "send_offer":  {"description": "send a written price offer for the unit they viewed", "value_sar": 289000, "cost_sar": 6},
    "hand_to_rep": {"description": "hand the lead to a human sales rep",                 "value_sar": 289000, "cost_sar": 40},
    "call_again":  {"description": "make an automated follow-up call",                   "value_sar": 178500, "cost_sar": 3},
    "wait_3_days": {"description": "wait three days without contact",                    "value_sar": 127500, "cost_sar": 0}},
  "rules": {"quiet_hours": {"start": "22:00", "end": "09:00", "actions": ["call_again"]},
            "max_count":   {"call_again": 3, "window_days": 7},
            "exclude": []},
  "objective": "expected_value",
  "exploration": 0.10
}
```

| field | default | what it is |
| - | - | - |
| `question` | | what is being decided, for the record |
| `candidates` | required | `{key: {description, value_sar, cost_sar}}`. Keys are letters, digits, `_`, `.` and `-`. `value_sar` is what one success through that action is worth (required unless the objective is `p_success`; the baseline may leave it out); `cost_sar` is paid every time (default 0). The order matters: it breaks ties |
| `baseline` | `no_action` when it is a candidate | the do-nothing candidate. See [the baseline](#the-baseline-what-happens-if-we-do-nothing) |
| `rules.quiet_hours` | none | `{start, end, actions, tz}`: `HH:MM` times, the actions it applies to (every candidate but the baseline when left out), the zone (`Asia/Riyadh` by default) |
| `rules.max_count` | none | `{action: N, ..., window_days}`: at most N of that action within `window_days` (default 7) before the decision |
| `rules.exclude` | `[]` | actions the caller rules out outright |
| `objective` | `expected_value` | `expected_value`, `uplift` or `p_success` |
| `exploration` | `0` | the rate e, between 0 and 1. See [exploration](#exploration-and-the-propensity) |
| `review` | `{"margin": 0.02, "min_p": 0.2}` | when to ask a person. See [review](#review) |
| `ledger` | `line` | what the ledger event's text says: `line`, the one line, or `complete`, the whole decision. See [the ledger](#writing-it-back-the-ledger) |

Beside the block, a request may carry:

| field | what it is |
| - | - |
| `chances` | your own chance per candidate, `{key: 0.34}` or `{key: {"p_success": 0.34, "evidence": [...]}}`. A candidate with one is not asked of the model; its `chance_source` is `request` |
| `journey` | the lead's events, for `max_count` and for the words of the evidence. A journey state (`{"segments", "journey", "decision_time"}`, what [the journey model](https://levers9.mintlify.site/lab/journey-decisions.md) reads) carries its own |
| `decision_time` | ISO time of the decision (a time without an offset is read in the rule's zone); a journey state carries its own; otherwise now |

Unknown actions in a rule, a time that is not `HH:MM`, an unknown zone, a chance outside 0 to 1 or a missing
chance are answered `422` with a sentence that names the field.

## How the decision step works

```mermaid theme={null}
flowchart LR
  subgraph IN["inputs"]
    C["chances per candidate<br/>p_a = p(success | history, do a)<br/>from the network or the request"]
    D["decision block<br/>candidates, value, cost<br/>rules, objective, e, review"]
    J["journey and decision_time"]
  end
  subgraph STEP["the decision step: fixed, no learned weights, after the network"]
    S1["1 chance"] --> S2["2 baseline"] --> S3["3 score"] --> S4["4 rules"] --> S5["5 rank"]
    S5 --> S6["6 review"] --> S7["7 explore"] --> S8["8 explain"] --> S9["9 record"]
  end
  subgraph OUT["output"]
    R["recommendation<br/>action, ranked, baseline<br/>propensity, review, why<br/>ledger_event"]
  end
  C --> S1
  D --> S1
  J --> S4
  S9 --> R
  R -->|"the caller appends ledger_event"| L[("the lead's ledger")]
  L -->|"the outcome event joins by decision_id"| T["a training example<br/>(history, action, outcome)<br/>weighted by 1 / propensity"]
  T -.->|"trains success_if_ fields"| C
```

| stage | reads | writes | why it is not learned |
| - | - | - | - |
| 1 chance | the network's `success_if_<a>` answers, or the request's `chances` | p\_a, `chance_source`, `trained` | the learned part lives in the network |
| 2 baseline | the do-nothing candidate's chance | p\_0 | uplift needs the counterfactual "do nothing" |
| 3 score | p, value, cost, objective | a score per candidate | business numbers change often; they belong in the request |
| 4 rules | quiet hours, max\_count, exclude, the journey, the decision time | `allowed`, `excluded_by` | compliance must be exact and explainable |
| 5 rank | the scores, the candidate order | the best allowed action | deterministic and reproducible |
| 6 review | margin, min\_p, uplift | `review`, `review_reasons` | a person decides close or doubtful calls |
| 7 explore | e, the allowed actions | the action taken, `explored`, `propensity` | keeps every action tried so its effect stays learnable; the propensity makes the logs usable for unbiased training and offline evaluation |
| 8 explain | the numbers, the rules, the evidence | `why` | auditable, never invented |
| 9 record | the choice, and everything that led to it | `decision_id`, `ledger_event` with its `record` | closes the loop through the ledger |

The code reads the same: `lab/rl/decision_step.py` has one function per stage (`chance`, `baseline`, `score`,
`rules`, `rank`, `review`, `explore`, `explain`, `record`); `recommend` runs all nine.

**Scores.** `expected_value`: p x value\_sar - cost\_sar. `uplift`: (p - p\_0) x value\_sar - cost\_sar, and 0 for the
baseline itself. `p_success`: p. Every candidate is ranked, the forbidden ones too (in their score's place, with
`allowed: false`); the best allowed action is the first allowed row, and equal scores keep the order the
candidates were given in.

## The recommendation

The block above, at 22:30 Riyadh time, with chances 34%, 29%, 21% and 15%:

```json theme={null}
"recommendation": {
  "decision_id": "dec-7f3a9c41b2e0", "action": "send_offer", "objective": "expected_value",
  "ranked": [
    {"action": "send_offer",  "p_success": 0.34, "value_sar": 289000, "cost_sar": 6,  "score": 98254, "allowed": true,  "evidence": ["ev-3", "ev-4"], "chance_source": "model", "trained": false},
    {"action": "hand_to_rep", "p_success": 0.29, "value_sar": 289000, "cost_sar": 40, "score": 83770, "allowed": true,  "evidence": ["ev-4"], "chance_source": "model", "trained": false},
    {"action": "call_again",  "p_success": 0.21, "value_sar": 178500, "cost_sar": 3,  "score": 37482, "allowed": false, "excluded_by": "quiet_hours", "chance_source": "model", "trained": false},
    {"action": "wait_3_days", "p_success": 0.15, "value_sar": 127500, "cost_sar": 0,  "score": 19125, "allowed": true,  "evidence": [], "chance_source": "model", "trained": false}],
  "explored": false, "exploration": 0.1, "propensity": 0.933333,
  "review": true, "review_reasons": ["untrained chance: the served model was never trained on send_offer; its 34% is a guess"],
  "why": "send_offer: 34% x SAR 289,000 - SAR 6 = SAR 98,254, best of 3 allowed; call_again excluded by quiet hours (22:00 to 09:00); evidence: price objection corrected (ev-3), viewing attended (ev-4)",
  "ledger_event": {"source": "engine", "type": "action", "action": "send_offer", "decision_id": "dec-7f3a9c41b2e0",
                   "propensity": 0.933333, "time": "2026-09-24T22:30:00+03:00",
                   "text": "Engine chose send_offer (considered: send_offer, hand_to_rep, wait_3_days); not allowed: call_again (quiet_hours)",
                   "form": "line", "record": {"options": [...], "baseline": null, "objective": "expected_value",
                                              "picked": "send_offer", "explored": false, "propensity": 0.933333,
                                              "review": true, "review_reasons": ["untrained chance: ..."]}}
}
```

| field | what it is |
| - | - |
| `decision_id` | `dec-` and a short id, minted per decision; on the ledger event and on every event the action causes |
| `action` | the action to take: the best allowed one, or an explored one. `null` when nothing is allowed |
| `ranked` | every candidate, best score first: its chance, numbers, score, `allowed` and `excluded_by` (`exclude`, `quiet_hours` or `max_count`), its `evidence` when the chance came with some, `chance_source` (`model` or `request`) and `trained` |
| `baseline` | `{action, p_success, source}` when there is a baseline, with `uplift` and `uplift_value_sar` on every ranked row |
| `explored`, `exploration`, `propensity` | whether the pick is not the best, the rate e, and the probability the step gave the action taken |
| `review`, `review_reasons` | whether a person should look first, and why, in plain words |
| `why` | a template of the numbers, the rules that ruled actions out, the evidence and the exploration; never generated text |
| `ledger_event` | the event to append to the lead's journey, with the complete decision in its `record`. `null` when nothing is allowed |

## The rules

**Quiet hours** are checked against the decision time in the rule's zone (`Asia/Riyadh` unless `tz` names
another). A window whose start is after its end crosses midnight: `22:00` to `09:00` holds from 22:00 up to, not
including, 09:00. The start is inside, the end is outside; equal start and end is no window.

**Max count** is counted from the journey's own engine action events within `window_days` before the decision
time (an event at or after it never counts). An engine action event is either
`{"source": "engine", "type": "action", "action": "call_again", "time": "..."}` or any event or plain line whose
text says `Engine chose call_again`, which is what the step itself writes. An engine event with no time counts: a
cap limits contact, and an event of unknown time cannot be shown to be outside the window. Contacts the engine
did not choose (a customer's call, a rep's own message) are not engine actions; to cap every contact, count them
yourself and rule the actions out with `exclude`.

**Exclude** rules actions out by name. When several rules forbid one action, `excluded_by` names the first of
exclude, quiet hours, max count.

## The baseline: what happens if we do nothing

A lead may convert anyway. An action is worth taking only for what it adds over doing nothing, so the step keeps
the counterfactual: the **baseline** candidate, `no_action` by default when it is among the candidates, or the one
`decision.baseline` names. It is a candidate like any other (value and cost usually 0), and its chance p\_0 comes
the same way: the model's `success_if_no_action`, or the request's `chances`.

With a baseline, every ranked row carries `uplift` = p\_a - p\_0 and `uplift_value_sar` = uplift x value\_sar, and
the recommendation names the baseline. The `uplift` objective scores by it:

```json theme={null}
"why": "send_offer: uplift +12 points over doing nothing (34% vs 22%) x SAR 289,000 - SAR 6 = SAR 34,674, best of 3 allowed"
```

Under `uplift` the baseline's own score is 0, so an action whose uplift does not pay for its cost loses to doing
nothing. Under the other objectives the why still says the uplift, and the step asks for a review when the best
action's uplift is not above 0: its chance is no better than doing nothing, and the honest pick is the baseline.
`objective: "uplift"` without a baseline is a 422.

## Exploration and the propensity

With exploration e and n allowed actions, the step takes the best action, except with probability e it draws one
of the n allowed actions uniformly (it may land on the best). The **propensity** of the action taken is
(1 - e) + e/n for the best and e/n for any other, so the allowed actions' propensities sum to 1; with e = 0, or
one allowed action, it is 1. A forbidden action is never drawn.

Exploration is what keeps every action's effect learnable: history only shows the outcome of the action actually
taken, and an action never tried has no outcomes. The logged propensity is what makes those logs usable: a
training example is weighted by 1/propensity, and a new policy can be evaluated offline on the old logs. The draw
uses the operating system's randomness; `exploration` is 0 unless you set it.

## Review

`review` is true, with a reason each, when:

* no action is allowed;
* the top two allowed scores are within `margin` of the best (relative: 0.02 is 2%);
* the best action's chance is below `min_p`;
* there is a baseline and the best action's uplift over it is not above 0;
* the best action's chance is the served model's answer for a candidate it was never trained on
  (`chance_source` "model", `trained` false), or the baseline's is. Such a chance is a guess, not a weak estimate:
  asked live with one untrained candidate beside four trained ones, the untrained one got between 60% and 92% on
  average, a harmful message included, and won the pick on most leads. Give the candidate's chance yourself in
  `chances`, or expect the review.

## Writing it back: the ledger

The decision step writes nothing anywhere: the caller appends `ledger_event` to the lead's journey, as history.
Fin reads only its text, its time, source and type; the propensity and the decision id are for training and
evaluation, so the policy stays out of the model's inputs.

**One line, or the complete decision.** The event's text is one line by default, "Engine chose send\_offer
(considered: ...)": the name of what was done and what else was allowed. When a rule shut a move, the line says so
after it, with the rule's own name: `; not allowed: call_again (quiet_hours)`. That is how Fin's training
journeys write a decision, and Fin reads the difference: asked live on 400 decisions of its validation set, with
the earlier decisions of each journey written without what a rule shut, its picks were worth SAR 291 less a
decision (95% interval 39 to 612); with it, nothing that can be told from zero. The event also carries the whole
decision as data, in `record`:

| in `record` | what it is |
| - | - |
| `read` | what the model answered beside the chances, `{question: {answer, p}}`. Left out when the request asked nothing else |
| `options` | every candidate in ranked order: `action`, `p_success`, `value_sar`, `cost_sar`, `score`, `uplift` when there is a baseline, `allowed`, `excluded_by`, `chance_source`, `trained` |
| `baseline`, `objective` | the do-nothing candidate and what was scored |
| `picked`, `explored`, `propensity` | what was done, whether it was a draw and not the best, and with what probability |
| `review`, `review_reasons` | whether it was flagged, and why |

Send `"ledger": "complete"` in the decision block and the text says all of it after the line, so the next request's
journey holds the earlier decision whole and not only its name:

```text theme={null}
Engine chose send_offer (considered: send_offer, hand_to_rep, wait_3_days); not allowed: call_again (quiet_hours).
Chances: send_offer 34%, hand_to_rep 29%, wait_3_days 15%. Not allowed: call_again 21% (quiet hours). Picked the best
by expected value, propensity 0.93. Review: untrained chance: the served model was never trained on send_offer; its
34% is a guess
```

With questions in the request it also says what was read, for example `Read: interest_now warm (62%),
live_objection price`; a reading the model was sure of (85% or more) is written without its percentage. Both forms
start with the same words, so `max_count` counts either. `record` is the same whichever form the text has, so a
caller can write the sentence later with its own words.

**Then write what you did.** The ledger event says what was decided. When you carry the decision out, append one
more event for what was done, a minute or so later: the offer as it went out, the follow-up call as placed, the
hand-over as a task for a rep. Fin's training journeys always have that row after a decision that does something,
and none after a decision to do nothing:

| the decision | the row that follows it |
| - | - |
| `send_offer` | `{"source": "WhatsApp", "type": "message", "text": "agent: As discussed, here is our written offer: 2% off, valid for 7 days"}`, or the CRM's own line for it: `{"source": "CRM", "type": "action", "text": "Offer sent to the customer"}` |
| `call_again` | `{"source": "engine", "type": "action", "text": "Follow-up call placed"}` |
| `hand_to_rep` | `{"source": "engine", "type": "action", "text": "Lead handed to a sales rep for a callback"}` |
| `no_action` | nothing |

Most systems log these by themselves: the message that was sent, the call the dialer placed, the task in the CRM.
What matters is that they reach the journey you send. Without them Fin counts fewer contacts than there were and
acts where it should wait: on the same 400 decisions, journeys with no such row and no shut moves in the line cost
its picks SAR 550 a decision (95% interval 212 to 923), one pick in ten changed. With both written as above the
picks are worth what they are on its own validation journeys (a difference of SAR 27, interval -381 to +308).

> [!WARNING]
>
> A model reads the complete decision only as well as it was trained on journeys written that way. A model trained on
> the one line may change its answers when it meets the longer text, for no better reason than that the text repeats
> its own earlier readings. Try both on your journeys before you switch: the decision graph of the
> [Decide playground](https://levers9.mintlify.site/lab/decide-playground.md) replays a journey both ways and says what changed.

An endpoint deployed before this returns the one line and no `record`; redeploy it to get them. The engine
hands the `decision_id` to whatever carries the action out (a call, a message, a CRM task), which echoes it on
the events it causes. The outcome event that later carries the same `decision_id` closes the loop: the history up
to the decision, the action, and whether it succeeded become one training example for that action's
`success_if_` field, weighted by 1/propensity. An outcome that never arrives within the window is unknown
(censored), not a failure.

## Where the chances come from

For every candidate the request gave no chance for, the served model is asked one binary question with the id
`success_if_<key>`:

```json theme={null}
"success_if_send_offer": {"type": "choice",
  "instructions": "If we send a written price offer for the unit they viewed now, will this lead succeed within the window?",
  "criteria": {"yes": "yes, the lead succeeds within the window after this action",
               "no": "no, the lead does not succeed within the window after this action"}}
```

Its p(yes) is the chance; any evidence the model returns (the past events the answer read most) rides along.
Write each `description` as what you would do ("send a written price offer"), since it is read in that sentence.
A chance is `"trained": true` only when the served model was trained on that field id (for a Fin release, the
fields of its `fin_config.json`); a Laya or Kev model is trained on no `success_if_` field. These answers are in
`answers` too. When every chance comes with the request, the model is asked nothing and `latency_ms` is 0.

**A release with a causal head answers by the move's name.** The Fin release served now has one output per move it
was trained on (`send_offer`, `call_again`, `hand_to_rep`, `no_action`), and the chance of a candidate with one of
those keys comes from that output: the question's words and the `description` never reach the network. Asked live
with the description reworded, with its own trained question sent in `questions`, and with no other question at
all, the four chances were the same to four decimals. So what matters for such a release is the key. A candidate
under another key (the lab's own `wait_3_days` where the model knows `no_action`) has no output of its own: it is
answered from the question's words, comes back `"trained": false`, and is flagged for review.

`GET /health` on the endpoint says which keys and fields those are, by name: `actions` (the moves the release has
an output for) and `trained_fields` (every field id of its schemas). Build the candidates and the questions from
them and not from a list of your own, which goes stale the day another release is served.

## Call it

On the endpoint itself (`/decide/batch` takes `states` and returns a recommendation per state, each with its own
journey and decision time):

```bash theme={null}
curl -s -X POST https://levers9.uselevers.com/asgi/lev/v1/decide \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{
  "state": {"segments": [], "journey": [], "decision_time": "2026-09-24T22:30:00+03:00"},
  "decision": {"candidates": {"send_offer": {"description": "send a written price offer", "value_sar": 289000, "cost_sar": 6},
                              "no_action": {"description": "do nothing for now"}},
               "objective": "uplift", "exploration": 0.1},
  "chances": {"send_offer": 0.34, "no_action": 0.22}
}' | jq .recommendation
```

Through the dashboard, which calls the deployed endpoint with the token stored when it was deployed:

```bash theme={null}
curl -s -X POST $DASH/api/rl/recommend -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{
  "state": {"segments": [], "journey": [], "decision_time": "2026-09-24T22:30:00+03:00"},
  "decision": {...}, "chances": {...}
}'
# {answers, recommendation, request, raw, model, latency: {model_ms, roundtrip_ms}}
```

`POST /api/rl/recommend` takes `state`, `decision`, and optionally `chances`, `questions`, `model`, `journey` and
`decision_time`. It answers `409` when no Decision API is deployed, and `502` with a sentence when the deployed
endpoint predates the decision step (redeploy it from **Lab → Decide**, or `levers9 decide serve`). A request
without a `decision` block is answered exactly as before.

## In the dashboard

The causality labs on **Lab → Decide** use it for the **Next-best action**: the lab sends the simulated world's
actions with their value and cost, its do-nothing action as the baseline, its quiet hours, and the chances its
estimator learned from simulated logs, and the Decision API's decision step makes the pick. The card says
**Decided by the Decision API's decision step**, shows the propensity, the review reasons and the server's why,
and gives each action its uplift over doing nothing. The lab's contact cap counts every contact in the
simulated ledger, so the lab applies it itself and sends the result as `exclude`. When the API cannot be reached
(none deployed, stopped, or deployed before the decision step), the browser's own step decides and the card
says **in-browser step**. The lab's **Decide** step, with its sliders, always decides in the browser.


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