Skip to main content
A Decision API 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.
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 has a full request and response.

The request

The canonical decision block, sent to POST /decide beside state (and questions, which become optional):
Beside the block, a request may carry: 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

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

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:
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: 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:
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: 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).
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 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>:
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):
Through the dashboard, which calls the deployed endpoint with the token stored when it was deployed:
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.