# Introduction
> What Dcision is — structured decisions over a state (choice, score and probability questions plus policies that pick an action), deployed as one API — and why it exists.
Source: https://docs.dcision.io/docs
**Dcision is Decision as a Service — the decision layer for AI.** LLMs reason, agents act, Dcision decides what happens next.
Most events in an AI product don't need a generated paragraph. They need a small, fast, typed answer: *should this lead go to sales?*, *does this ticket need a human?*, *is this chunk relevant?*, *which tool should the agent call?* Dcision turns each of those questions into a **decision** you design once, deploy as an endpoint and call millions of times.
## Why decisions, not text [#why-decisions-not-text]
Most AI is built for a conversation between a model and a person. Production automation is mostly **machine to machine**: code asking a model for one narrow judgment it can inspect and act on. That needs what TypeSafe calls *machine native intelligence* — AI with the properties of software: structure, reliability, observability, testability, speed, consistency and low cost.
Dcision runs on **Jev**, TypeSafe's System One model, trained with RLCD — *reinforcement learning for calibrated decisions*. Jev doesn't generate text: it returns decisions and **calibrated probabilities**, so across many answers, those given 0.8 should be right about 80% of the time. Chat models are tuned toward answers people prefer, which can reward confident-sounding mistakes; a calibrated number is something your software can threshold, escalate on and audit. Read TypeSafe's [AI primer](https://docs.typesafe.ai/introduction/machine-learning-primer) for the background.
Dcision adds what production needs around that engine: a typed contract per decision, policies that turn answers into actions, versioned deploys, execution logs, API keys, quotas and billing.
## How it works [#how-it-works]
You describe a decision with a [decision schema](https://docs.dcision.io/docs/concepts/decision-schema):
* **State** — the input your application sends: a JSON object with typed fields, plain text, or a list such as a chat transcript.
* **Questions** — what to decide about the state, up to 64 per decision, all answered in one call. Three types:
* **choice** — pick one option (a team, a category, a tool). A reserved `other` option lets the engine say "none of these".
* **score** — place the state on an ordered scale (`low` → `critical`).
* **probability** — a yes/no question answered with a number between 0 and 1.
* **Composites** — optional weighted combinations of answers, such as a lead score, computed by Dcision.
* **Policies** — rules evaluated after the answers, such as *if `route` = `spam` then `block`*, with optional `and` conditions. They set the **action** your code acts on: `continue`, `block`, `escalate` or `fallback`.
* **Destinations** — optional: what happens next for each result. Return a fixed reply or an LLM answer, hand over to your agent, trigger a workflow, send a signed webhook, call an API or tell your code which function to run. See [Destinations](https://docs.dcision.io/docs/destinations).
You test the decision in the [Playground](https://docs.dcision.io/docs/concepts/playground), [deploy](https://docs.dcision.io/docs/concepts/versions-and-deploy) it as an immutable version and call it:
```bash
curl -X POST https://api.dcision.io/v1/decisions/lead-qualification \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "state": { "message": "We need pricing for 500 users and want to start next month.", "company_size": 500 } }'
```
```json title="Response (abridged)"
{
"result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
"confidence": { "purchase_intent": 0.8824, "priority": 0.69, "route": 0.85 },
"scores": { "priority": 2.81 },
"composites": { "lead_score": 0.8414 },
"action": "continue",
"action_reason": { "type": "default" }
}
```
Every answer is typed: a choice is always one of your options, a score is always one of your labels, a probability is always a number in `[0, 1]`. There is nothing to parse and no prompt to keep in sync with your code.
## What happens on every call [#what-happens-on-every-call]
```text
state ──▶ validate (shape + token budget) ──▶ engine-neutral representation ──▶ engine (Jev)
──▶ typed answers, confidence, weighted levels ──▶ composites ──▶ policies ──▶ action + reason
──▶ destinations ──▶ execution log ──▶ response
```
1. The state is validated against the decision's state schema and the engine's token budget. A bad state fails fast with `422 INVALID_STATE` and never reaches the engine.
2. The decision is compiled into an engine-neutral representation and sent to the engine — today **Jev**, on TypeSafe — within the decision's time budget, with retries.
3. The engine returns a probability distribution for each question. Dcision normalizes it into `result` (the answer), `confidence` and, for score questions, `scores` (the weighted level).
4. Composites are computed; then policies, the reserved `other` option and minimum-confidence thresholds produce the `action` and its `action_reason`.
5. The decision's [destinations](https://docs.dcision.io/docs/destinations) that match the result run: replies and functions are returned, LLM and agent answers are awaited, and webhooks, workflows and API requests are queued for delivery.
6. The run is recorded as an [execution](https://docs.dcision.io/docs/concepts/executions-and-usage) — with or without the input and output, as you configure.
## Why Dcision [#why-dcision]
* **Cheap enough for every event.** Jev bills input tokens only. Each response reports `metrics.input_tokens` and `metrics.estimated_cost_usd`, and plans include millions of decisions per month — the free Genesis plan includes 1M. A decision is billed once, however many questions it asks.
* **Typed and stable.** The response contract comes from your schema, keyed by your question keys. If the engine ever answers outside the contract, the call fails with `ENGINE_ERROR` instead of returning malformed data.
* **Honest about uncertainty.** Calibrated confidence, the `other` option, per-question minimum confidence and a fallback action make "I'm not sure" an explicit, routable outcome — see [Handling low confidence and `other`](https://docs.dcision.io/docs/guides/low-confidence-and-other).
* **Built for composition.** TypeSafe's four [patterns](https://docs.dcision.io/docs/patterns) — fan-out, confidence-gated routing, composite scoring and intent routing — are features of the schema, each with a template.
* **Auditable.** Deployed versions are immutable snapshots. Each execution keeps the version, action reason, confidence, distributions, model, latency and request ID.
* **Engine-agnostic.** The decision schema doesn't depend on any engine. Jev comes first; run it on Dcision's key or [bring your own key](https://docs.dcision.io/docs/concepts/engines-and-byok) for OpenRouter, TypeSafe or Vercel AI Gateway.
## Good fits [#good-fits]
Routing (leads, tickets, agent tools), triage and prioritization, moderation and spam, relevance checks before an LLM call, scoring and ranking, gating ("does this need a human?" / "does this need a bigger model?"). Dcision is **not** a text generator: use it to decide *whether* and *where* to send work, then let your LLM, agent or team do it.
Answers are probabilities, not guarantees. For decisions with legal or similarly significant effects — credit, employment, healthcare, legal — use Dcision to triage and route, and send the final call to a person (for example with an `escalate` policy).
## Next steps [#next-steps]
---
# Quickstart
> Sign up, create a decision from a template, test it in the Playground, deploy it and call it with cURL, JavaScript or Python — in about five minutes.
Source: https://docs.dcision.io/docs/quickstart
This walkthrough follows the checklist on your Dashboard: create a decision, test it, deploy it, create an API key and make your first API call.
### Create your account [#create-your-account]
Open [app.dcision.io](https://app.dcision.io) and choose **Continue with Google** or enter your work e-mail to receive a **6-digit code** (valid for 10 minutes).
The first sign-in creates your account and a workspace on the free **Genesis** plan (1M decisions per month, 2 decisions, 7-day logs — see [Plans](https://docs.dcision.io/docs/plans-and-billing)).
### Create a decision from a template [#create-a-decision-from-a-template]
Go to **Templates** and pick **Lead Qualification** under *All templates* (or **Decisions → New decision** and select the template), then click **Create decision**.
You land in the editor with an editable draft that contains:
| Part | Content |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| State | `message` (string, required), `company_size` (number), `source` (string) |
| Questions | `purchase_intent` (probability), `priority` (score: `low` → `critical`), `route` (choice: `sales`, `sdr`, `nurture`, `spam` + the reserved `other`) |
| Composite | `lead_score` — a 0–1 ranking from intent (weight 2), priority (1) and the probability of `sales` (1) |
| Policies | `route` = `spam` → `block`; `purchase_intent` confidence below 0.6 → `escalate` |
The **Patterns** panel next to the schema shows that this decision already combines three of TypeSafe's [patterns](https://docs.dcision.io/docs/patterns): intent routing, confidence-gated routing and composite scoring.
The **slug** — your endpoint — is generated from the name: `lead-qualification` (or `lead-qualification-2` if the workspace already uses it). You can change it until the first deploy.
### Test it in the Playground [#test-it-in-the-playground]
Open the **Playground** tab, replace the state with the JSON below and click **Run decision** (or press ⌘/Ctrl + Enter):
```json
{
"message": "We need pricing for 500 users and want to start next month.",
"company_size": 500,
"source": "website"
}
```
You get the typed result, the action and why it was chosen, a confidence bar per question, the full probability distribution of every option and the value of `lead_score`. Playground runs are not billed and appear in **Executions**. Edits to the draft are picked up even before they are saved.
### Deploy it [#deploy-it]
Open the **Deploy** tab and click **Deploy v1**. Dcision snapshots the draft as immutable **version 1** and makes it live:
```text
POST https://api.dcision.io/v1/decisions/lead-qualification
```
From now on the slug is locked. Later edits stay in the draft until you deploy v2 — production never changes by accident. See [Versions and deploy](https://docs.dcision.io/docs/concepts/versions-and-deploy).
### Create an API key [#create-an-api-key]
Go to **API Keys → New key**, keep the name `Production` and the **Live** environment, and click **Create key**. Copy the key — it starts with `dcs_live_` and **is shown only once** — and store it as an environment variable on your server:
```bash
export DCISION_API_KEY="dcs_live_…"
```
Never ship the key to a browser or a mobile app. See [Authentication](https://docs.dcision.io/docs/api/authentication).
### Call the API [#call-the-api]
```bash
curl -X POST https://api.dcision.io/v1/decisions/lead-qualification \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"state": {
"message": "We need pricing for 500 users and want to start next month.",
"company_size": 500,
"source": "website"
}
}'
```
```js
// Node.js 18+ (global fetch), server-side only.
const response = await fetch("https://api.dcision.io/v1/decisions/lead-qualification", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DCISION_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
state: {
message: "We need pricing for 500 users and want to start next month.",
company_size: 500,
source: "website",
},
}),
});
const decision = await response.json();
if (!response.ok) throw new Error(`${decision.error.code}: ${decision.error.message}`);
console.log(decision.action, decision.result);
```
```python
import os
import requests
response = requests.post(
"https://api.dcision.io/v1/decisions/lead-qualification",
headers={"Authorization": f"Bearer {os.environ['DCISION_API_KEY']}"},
json={
"state": {
"message": "We need pricing for 500 users and want to start next month.",
"company_size": 500,
"source": "website",
}
},
timeout=10,
)
decision = response.json()
if not response.ok:
raise RuntimeError(f"{decision['error']['code']}: {decision['error']['message']}")
print(decision["action"], decision["result"])
```
The official [SDKs](https://docs.dcision.io/docs/sdks) for TypeScript and Python wrap this call with safe retries — `await dcision.decide("lead-qualification", state)`. To call it from a tool that can't hold an API key — a form, a CRM, Zapier, n8n or Make — turn on the decision's [webhook URL](https://docs.dcision.io/docs/api/webhook-trigger) in its **Advanced** tab. To let an AI agent call it, connect the [MCP server](https://docs.dcision.io/docs/mcp) — see [Use Dcision in Claude Code](https://docs.dcision.io/docs/claude-code).
The response (values vary from run to run; the shape is exact):
```json title="200 OK"
{
"decision_id": "dec_3fKq9ZtW1mXcV7bN2pLa",
"execution_id": "exec_8HsT2kQw9ZyR4vMn1cXe",
"schema": "lead-qualification",
"version": 1,
"result": {
"purchase_intent": 0.9412,
"priority": "high",
"route": "sales"
},
"confidence": {
"purchase_intent": 0.8824,
"priority": 0.69,
"route": 0.85
},
"scores": { "priority": 2.81 },
"composites": { "lead_score": 0.8414 },
"action": "continue",
"action_reason": { "type": "default" },
"metrics": {
"latency_ms": 412,
"engine": "jev",
"model": "jev-1.13.0",
"estimated_cost_usd": 0.00001575,
"input_tokens": 375,
"output_tokens": 36
}
}
```
## Read the response [#read-the-response]
* `result` holds one answer per question, keyed by the question key: a number in `[0, 1]` for probability questions, a scale label for score questions and an option for choice questions.
* `confidence` holds how sure the engine is about each answer, from 0 to 1 — see [Confidence and probabilities](https://docs.dcision.io/docs/concepts/confidence-and-probabilities).
* `scores` holds the weighted level of each score question, counted from 1: `priority` at 2.81 sits between `medium` (2) and `high` (3), closer to `high`.
* `composites` holds the decision's weighted combinations — here `lead_score`. See [Composites](https://docs.dcision.io/docs/concepts/composites).
* `action` is what your code should do: `continue`, `block`, `escalate` or `fallback`. `action_reason` says why — here no policy matched, so the default `continue` applies. See [Policies and actions](https://docs.dcision.io/docs/concepts/policies-and-actions).
* `version` is the deployed version that answered and `execution_id` finds the run in **Executions**.
* `metrics` reports the latency, the exact model that answered, the input and output tokens and the estimated engine cost — Jev bills input tokens only.
Add `?include=probabilities` to also receive the full distribution of every question — see [Confidence and probabilities](https://docs.dcision.io/docs/concepts/confidence-and-probabilities).
## Next steps [#next-steps]
---
# Decision schema
> The JSON document behind every decision — the state it accepts, the questions it answers, composites, the policies that pick the action, the runtime settings and the destinations.
Source: https://docs.dcision.io/docs/concepts/decision-schema
A decision is defined by one JSON document, the **decision schema**. The editor and the visual builder edit it for you (the right-hand panel shows it under **Decision schema**), the [CLI](https://docs.dcision.io/docs/cli) scaffolds and validates it offline, and every deployed version stores a complete snapshot of it.
The schema is engine-neutral: nothing in it is specific to Jev or any other engine.
## A complete example [#a-complete-example]
This is the **Lead Qualification** template as it is stored, with every default filled in:
```json title="decision.json"
{
"stateSchema": {
"kind": "object",
"fields": [
{ "key": "message", "type": "string", "required": true, "description": "Inbound lead message" },
{ "key": "company_size", "type": "number", "required": false, "description": "Employee count" },
{ "key": "source", "type": "string", "required": false, "description": "website, referral, ads…" }
]
},
"questions": [
{
"key": "purchase_intent",
"type": "probability",
"instructions": "Does this lead have real intent to buy in the next 30 days?",
"criteria": {
"yes": "asks for pricing, a demo, a quote or a start date",
"no": "just browsing, student, vendor or spam"
}
},
{
"key": "priority",
"type": "score",
"instructions": "How urgent is it to answer this lead?",
"scale": ["low", "medium", "high", "critical"]
},
{
"key": "route",
"type": "choice",
"instructions": "Which team should receive this lead?",
"options": [
{ "value": "sales", "description": "high intent, ready to talk to sales" },
{ "value": "sdr", "description": "some intent, needs qualification" },
{ "value": "nurture", "description": "early stage, send content" },
{ "value": "spam", "description": "spam, vendor pitch or irrelevant" },
{ "value": "other", "description": "none of the options above" }
]
}
],
"composites": [
{
"key": "lead_score",
"description": "0–1 ranking: intent counts double, priority adds, spam-like routes don't count",
"terms": [
{ "question": "purchase_intent", "weight": 2 },
{ "question": "priority", "weight": 1 },
{ "question": "route", "option": "sales", "weight": 1 }
]
}
],
"policies": [
{ "field": "route", "on": "output", "operator": "eq", "value": "spam", "action": "block" },
{ "field": "purchase_intent", "on": "confidence", "operator": "lt", "value": 0.6, "action": "escalate" }
],
"settings": {
"storeInput": true,
"storeOutput": true,
"timeoutMs": 5000,
"fallbackAction": "escalate",
"onEngineError": "error"
}
}
```
## Top-level fields [#top-level-fields]
| Field | Type | Required | Description |
| -------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stateSchema` | object | Yes | The input the decision accepts — see [State schema](#state-schema). |
| `questions` | array | Yes | 1 to 64 questions — see [Questions](https://docs.dcision.io/docs/concepts/questions). |
| `composites` | array | No | Up to 20 weighted combinations of answers. Default `[]` — see [Composites](https://docs.dcision.io/docs/concepts/composites). |
| `policies` | array | No | Up to 50 rules, evaluated in order. Default `[]` — see [Policies and actions](https://docs.dcision.io/docs/concepts/policies-and-actions). |
| `context` | string | No | Up to 8,000 characters of business context sent with every state — see [Context](#context). |
| `settings` | object | No | Engine, storage, time budget, fallback action and engine-error behavior — see [Decision settings](https://docs.dcision.io/docs/concepts/settings). |
| `destinations` | array | No | Up to 20 destinations — what happens after the decision, per option, level, threshold or final action. Default `[]` — see [Destinations](https://docs.dcision.io/docs/destinations). |
## Keys [#keys]
State fields, question keys, composite keys and choice option values share one rule because they become JSON keys and values in the API response:
* **snake_case**: start with a lowercase letter, then lowercase letters, digits or `_`;
* **at most 48 characters** (pattern `^[a-z][a-z0-9_]{0,47}$`);
* **unique**: question and composite keys within the decision, field keys within the state, option values within a question.
## State schema [#state-schema]
The state is what your application sends in the request body as `{ "state": … }`. Choose one of three kinds — the same three shapes Jev evaluates natively:
| Kind | Send | Good for |
| -------- | --------------------------------------- | -------------------------------------------------------------- |
| `object` | a JSON object with typed fields | most decisions: named fields keep each part of the input clear |
| `text` | a non-empty string | one message, comment or passage |
| `list` | a non-empty array of strings or objects | a chat transcript, a batch of records |
### Object state [#object-state]
```json
{ "kind": "object", "fields": [{ "key": "message", "type": "string", "required": true }] }
```
`fields` holds up to 50 field definitions:
| Property | Type | Default | Description |
| ------------- | ------- | ------- | ---------------------------------------------------------------------------------- |
| `key` | string | — | The JSON key in the state. |
| `type` | string | — | One of `string`, `number`, `integer`, `boolean`, `array`, `object`. |
| `required` | boolean | `false` | Reject states that don't have this field. |
| `description` | string | — | Up to 255 characters. Documents the field in the API contract shown by the editor. |
How the state is validated before the engine runs:
* the state must be a JSON object (not an array, a string or `null`);
* a `required` field must be present and not `null`; an optional field may be missing or `null`;
* types are strict: `number` is any finite number, `integer` a whole number, `object` a non-null object that isn't an array — `"500"` is not a `number`;
* **fields that aren't declared are accepted and forwarded** to the engine, so you can send extra context without changing the schema;
* the first failing field is reported, for example `422 INVALID_STATE` with `"Field company_size must be a number."`.
The engine receives your state's keys and values, the decision's `context` and the questions — not the field descriptions. Give the engine what it needs through descriptive field names, `context` and precise instructions, and point instructions at fields by name: ``"Does `message` ask for a demo?"``.
### Text state [#text-state]
```json
{ "kind": "text" }
```
The state must be a **non-empty string**: `{ "state": "Congratulations! You won a $1000 gift card…" }`. The engine receives the string itself.
### List state [#list-state]
```json
{ "kind": "list", "items": "string" }
```
The state must be a **non-empty array** of up to **500 items**. `items` sets what each item is:
| `items` | Each item must be | Example state |
| ------------------ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `string` (default) | a string | `["Hi!", "My card was charged twice.", "Can you refund one?"]` |
| `object` | a JSON object (not `null`, not an array) | `[{ "role": "customer", "text": "My card was charged twice." }, { "role": "agent", "text": "Let me check." }]` |
The engine receives the array itself. Errors name the problem: `"Field state must be a non-empty array."`, `"Field state has too many items (612, max 500)."` or `"Item 2 of state must be an object."`.
A list is **one** state: every question is answered about the list as a whole — "does this conversation ask for a refund?" — not once per item. To classify items independently, send one decision per item, or use an object state such as `{ "messages": [ … ] }` with one question per position (``"Is `messages[2]` a complaint?"``).
### Size limits [#size-limits]
The serialized state must fit in **128 KB** and in about **32,000 tokens** (estimated as characters ÷ 4).
Jev also has a token budget for the whole call: about **32,000 tokens for the state plus the longest question** and **64,000 tokens for the state plus all questions**. Dcision checks both before calling the engine; a state over the limits fails with `422 INVALID_STATE` and a message that says which limit was hit — for example *"The state plus all questions is too large for one engine call (\~65210 tokens, max 64000). Shorten the state or split the decision."* See [Limits](https://docs.dcision.io/docs/api/limits).
## Context [#context]
`context` is free text, up to 8,000 characters, sent with every state under the reserved key **`decision_context`** — for example *"Inbound messages from the pricing page of a B2B SaaS. Prices start at $29/month."* Describe the business, not the expected answer.
How the engine receives it:
| State kind | What the engine evaluates when `context` is set |
| -------------- | ------------------------------------------------------------ |
| `object` | your object plus a `decision_context` field |
| `text`, `list` | `{ "decision_context": "", "input": }` |
Without `context`, the engine receives your state unchanged.
`decision_context` is reserved: when the decision has a `context`, it replaces a `decision_context` field your application sends in an object state. Any other field — including one named `context` — is forwarded as is.
## Structured entries [#structured-entries]
Instructions, choice option descriptions, score levels and probability criteria accept **JSON objects or arrays** as well as text — Jev reads structure natively. Use it to label the parts of a long question, to pass supporting data, or to give each option a rubric. See [Structured instructions and criteria](https://docs.dcision.io/docs/concepts/questions#structured-instructions-and-criteria).
## Validation [#validation]
The same rules run everywhere:
* **Editor** — validates as you type, shows each issue with a *Go to error* link and only auto-saves valid drafts.
* **API** — saving or deploying an invalid schema fails with `422 INVALID_SCHEMA`; `details` lists every issue with its path:
```json
{
"error": {
"code": "INVALID_SCHEMA",
"message": "The decision schema is invalid.",
"request_id": "req_7Gm2xPq9Lk4sVt1RbN8w",
"details": [
{ "path": "questions.0.options", "message": "Add at least 2 options (besides \"other\")." },
{ "path": "policies.1.field", "message": "Unknown question \"intent\"." },
{ "path": "composites.0.terms.2.option", "message": "Pick the option whose probability counts." }
]
}
}
```
* **CLI** — `dcision validate decision.json` checks a file offline, and `dcision check-state` checks a state against it.
Validation rules can get stricter over time — since v0.3, for example, rule values are checked against the options, levels and ranges they compare with (*"Use a number between 0 and 1 (e.g. 0.7, not 70)."*). Stricter rules apply when you **save or deploy**; versions already deployed keep running unchanged, so a tightened rule never turns a live endpoint into an error.
## The API contract [#the-api-contract]
The editor's **API contract** view shows what clients of the decision can rely on: the state as JSON Schema, the shape of each question's answer and the composites.
```json
{
"name": "lead-qualification",
"state": {
"type": "object",
"required": ["message"],
"properties": {
"message": { "type": "string", "description": "Inbound lead message" },
"company_size": { "type": "number", "description": "Employee count" },
"source": { "type": "string", "description": "website, referral, ads…" }
}
},
"questions": {
"purchase_intent": { "type": "probability", "range": [0, 1] },
"priority": { "type": "score", "scale": ["low", "medium", "high", "critical"] },
"route": { "type": "choice", "options": ["sales", "sdr", "nurture", "spam", "other"] }
},
"composites": {
"lead_score": { "type": "number" }
}
}
```
A text state is shown as `{ "type": "string" }` and a list state as `{ "type": "array", "items": { "type": "string" } }` (or `"object"`). Score scales list the level labels, also for structured levels. `composites` only appears when the decision has some.
---
# Questions
> Choice, score and probability questions — structured instructions and criteria, what the API returns for each, weighted levels, the reserved other option and minimum confidence.
Source: https://docs.dcision.io/docs/concepts/questions
Questions are what a decision answers about the state. A decision has **1 to 64 questions**, all answered in **one engine call**: Jev evaluates each of them independently and in parallel against the same state. Each question becomes a key in the response's `result` and `confidence` objects, in the order you define them.
| Type | Use it for | `result[key]` | Example |
| ------------- | ---------------------------------------------- | ------------------------------------------- | --------- |
| `choice` | Picking one option: a team, a category, a tool | One of your option values (string) | `"sales"` |
| `score` | Placing the state on an ordered scale | The label of the most likely level (string) | `"high"` |
| `probability` | A yes/no question | Probability of *yes*, from 0 to 1 | `0.9412` |
## Common properties [#common-properties]
| Property | Type | Required | Description |
| --------------- | ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key` | string | Yes | snake_case, unique in the decision. Becomes the JSON key in the response. |
| `type` | string | Yes | `choice`, `score` or `probability`. |
| `instructions` | string, object or array | Yes | The question the engine answers: text, or [structured JSON](#structured-instructions-and-criteria). Up to 8,000 characters (JSON counts its serialized length). |
| `minConfidence` | number | No | 0 to 1. Below it, the decision applies the fallback action — see [below](#minimum-confidence). |
## Choice [#choice]
```json
{
"key": "route",
"type": "choice",
"instructions": "Which team should receive this lead?",
"options": [
{ "value": "sales", "description": "high intent, ready to talk to sales" },
{ "value": "sdr", "description": "some intent, needs qualification" },
{ "value": "nurture", "description": "early stage, send content" }
]
}
```
* `options` needs **at least 2** and at most **254** options, plus `other` — 255 in total, Jev's maximum.
* `value` follows the [key rules](https://docs.dcision.io/docs/concepts/decision-schema#keys) and must be unique within the question.
* `description` tells the engine **when the option applies**: text or JSON, up to 2,000 characters. It is optional — omit it, or set it to `null`, when the value says it all (`"yes_refund"`). Descriptions decide accuracy: contrast similar options and add exclusions such as "not for existing customers".
* **Order matters.** Jev can lean toward the option listed first. Test important questions with the options reordered in the Playground and check that the answer stays the same.
### The reserved `other` option [#the-reserved-other-option]
Every choice question has an `other` option. Dcision adds it automatically as the **last** option, with the description `none of the options above`. It can't be removed, but you can describe it yourself by including it in `options` — wherever you put it, it is moved to the end:
```json
{ "value": "other", "description": "not about our product, or a language we don't support" }
```
Without `other`, an engine is forced to pick the least wrong option. With it, "none of these" becomes an explicit answer: when a choice question answers `other` and no policy matched first, the decision applies its **fallback action** with `action_reason.type = "other_option"`. See [Policies and actions](https://docs.dcision.io/docs/concepts/policies-and-actions).
### What the API returns [#what-the-api-returns]
* `result.route` — the chosen option, for example `"sales"` (or `"other"`).
* `confidence.route` — how far the chosen option stands above an even split between all the options, from 0 to 1 — see [Confidence](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#confidence).
* With `?include=probabilities`, `probabilities.route` holds one probability per option, `other` included.
Policies on a choice answer can only use `eq` and `neq`. The comparison ignores case and surrounding spaces.
## Score [#score]
```json
{
"key": "priority",
"type": "score",
"instructions": "How urgent is it to answer this lead?",
"scale": ["low", "medium", "high", "critical"]
}
```
* `scale` lists **2 to 10 levels from lowest to highest**. A level is a label (up to 2,000 characters) or a [structured rubric](#structured-score-levels) with a `label`. Labels must be unique.
* `result.priority` is the label of the **most likely level** — not an average — and `confidence.priority` says how concentrated the distribution is around it.
* `scores.priority` is the **weighted level**: the probability-weighted average of the levels, counted from 1. In the scale above, `2.81` sits between `medium` (2) and `high` (3), closer to `high`. Use it to act between levels.
* With `?include=probabilities`, `probabilities.priority` holds one probability per label, in scale order.
Policies compare **levels**, numbered from 1: in the scale above `low` is 1 and `critical` is 4.
* On the answer (`"on": "output"`), a rule uses the level number (`"value": 3`) or the label (`"value": "high"`), with any operator: `{ "field": "priority", "operator": "gte", "value": "high", "action": "escalate" }` matches `high` and `critical`. A label that isn't in the scale never matches.
* On the weighted level (`"on": "score"`), a rule compares `scores.priority` with a number: `{ "field": "priority", "on": "score", "operator": "gte", "value": 3.5, "action": "escalate" }` fires when the weight leans toward `critical`, even if `high` is the most likely level.
## Probability [#probability]
```json
{
"key": "purchase_intent",
"type": "probability",
"instructions": "Does this lead have real intent to buy in the next 30 days?",
"criteria": {
"yes": "asks for pricing, a demo, a quote or a start date",
"no": "just browsing, student, vendor or spam"
}
}
```
* `criteria.yes` and `criteria.no` are optional — text or JSON, up to 2,000 characters each — and say what counts as yes and as no. Keep them aligned with the instructions: `yes` should describe the yes case.
* `result.purchase_intent` is the **probability of yes**, between 0 and 1, rounded to 4 decimals.
* `confidence.purchase_intent` is `|2p − 1|`: the distance from a coin flip. `0.03` (a confident *no*) and `0.97` (a confident *yes*) both have confidence `0.94`; `0.5` has confidence `0`.
* With `?include=probabilities`, `probabilities.purchase_intent` is `{ "yes": p, "no": 1 − p }`.
Policies compare the probability with a number, for example `{ "field": "purchase_intent", "operator": "gte", "value": 0.8, "action": "continue" }`.
## Structured instructions and criteria [#structured-instructions-and-criteria]
Jev reads structure natively. Wherever a question has text — `instructions`, option `description`, score levels, `criteria.yes` and `criteria.no` — you can send a **JSON object or array** instead. Structure helps when a question has several parts, or when it needs supporting data such as a taxonomy, a schema or a database row: the keys label each part.
```json
{
"key": "duplicate_candidate",
"type": "probability",
"instructions": {
"potential_duplicate": { "name": "John Smith", "location": "Oakland, California", "last_employer": "Google" },
"question": "Is the resume in `resume` for the same person as `potential_duplicate`?"
}
}
```
* **Point at data with backticks.** Name state fields and keys of the instruction between backticks — `` `resume` ``, `` `ticket.messages[0].text` `` — to tell the engine exactly what to look at.
* **Give options a rubric.** An option description such as `{ "what": "Charges, invoices, refunds", "not_for": "Order tracking", "examples": ["I was charged twice"] }` sharpens the boundary between neighbors.
* **Limits are the same as for text**, measured on the serialized JSON: 8,000 characters for instructions, 2,000 for an option description, a score level or a criterion. Objects and arrays can't be empty.
* In the editor, paste a JSON object or array into the field: it is stored as structure and marked *structured*. Anything that isn't valid JSON stays text.
### Structured score levels [#structured-score-levels]
A score level can be an object with a **`label`** — the label is what `result` returns and what policies compare; the whole object is what the engine reads:
```json
{
"key": "bug_severity",
"type": "score",
"instructions": "If this describes a bug, how severe is it for the customer?",
"scale": ["cosmetic", "minor", "major", { "label": "critical", "rubric": "outage, data loss, security issue or money at risk" }]
}
```
The label is 1 to 255 characters; the whole level, serialized, up to 2,000. Plain and structured levels can be mixed in one scale. The editor adds levels as plain labels and shows structured ones with a braces icon — the [Support Ticket Triage](https://docs.dcision.io/docs/patterns/fan-out) template starts with one.
## Minimum confidence [#minimum-confidence]
Any question can set `minConfidence` between 0 and 1. When the answer's confidence is **below** it and no policy matched first, the decision applies its fallback action with `action_reason.type = "low_confidence"`:
```json
{ "type": "low_confidence", "question": "route", "confidence": 0.52, "minConfidence": 0.8 }
```
In the editor, the **Minimum confidence** toggle starts at 80%. Without it, low-confidence answers still `continue`.
For a **probability** question, confidence is `|2p − 1|`, so a minimum confidence flags a band of probabilities around 0.5: `0.8` flags every `p` between 0.1 and 0.9, `0.6` every `p` between 0.2 and 0.8. The editor shows the band next to the slider. See [Confidence and probabilities](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#minimum-confidence-on-probability-questions) and [Handling low confidence and `other`](https://docs.dcision.io/docs/guides/low-confidence-and-other).
## Tips [#tips]
* **Ask one thing per question.** Split "is this urgent spam?" into a probability (`spam`) and a score (`urgency`) — they cost one call either way.
* **Phrase instructions as a literal question** about the state: "Which team should receive this lead?".
* **Prefer probability for yes/no** — the number is easier to threshold than a two-option choice.
* **Prefer score for ordered levels** — policies can then use `gte`/`lte` on the level and thresholds on the weighted level.
* **Write option descriptions as criteria**, not labels, and make neighbors mutually exclusive. If the Playground flags a [near tie](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#near-ties), the descriptions overlap.
* **Put business facts in `context`**, not in every instruction.
* **Keep math, counts and dates in code** and send the results in the state.
* **Order matters**: when several questions answer `other` or fall below their minimum confidence, the first one in question order is reported in `action_reason`.
More in [Writing good questions](https://docs.dcision.io/docs/guides/writing-good-questions).
---
# Composites
> Combine several answers into one number with weights you control — Σ(weight × value) / Σ|weight| — return it in composites and use it in policies.
Source: https://docs.dcision.io/docs/concepts/composites
A **composite** is a weighted combination of answers that Dcision computes after the engine answers. Use one to rank (a lead score, a resume fit, a risk score), to threshold on several signals at once, or simply to keep the weights in the decision instead of in a prompt.
The engine answers small, atomic questions; the composite does the arithmetic. That split matters: Jev is good at judgment and weak at math, so the math stays in Dcision — exact, versioned and easy to tune. It is the [composite scoring](https://docs.dcision.io/docs/patterns/composite-scoring) pattern built into the schema.
## Define a composite [#define-a-composite]
Composites live in the schema's `composites` array. This one comes from the [Lead Qualification](https://docs.dcision.io/docs/guides/lead-qualification) template:
```json
{
"key": "lead_score",
"description": "0–1 ranking: intent counts double, priority adds, spam-like routes don't count",
"terms": [
{ "question": "purchase_intent", "weight": 2 },
{ "question": "priority", "weight": 1 },
{ "question": "route", "option": "sales", "weight": 1 }
]
}
```
| Property | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `key` | string | Yes | snake_case, unique across the decision's questions **and** composites. The key in the response's `composites` and in policies. |
| `description` | string | No | Up to 500 characters. Documentation only: it isn't sent to the engine. |
| `terms` | array | Yes | 1 to 20 terms. |
Each term points at one question:
| Property | Type | Required | Description |
| ---------- | ------ | --------------------- | ---------------------------------------------------------------------------------------------- |
| `question` | string | Yes | The key of a question of this decision. |
| `option` | string | Choice questions only | The option whose probability counts — required for a choice question, rejected for the others. |
| `weight` | number | Yes | From −100 to 100, not 0. Negative weights subtract. |
A decision has up to **20 composites**. Mistakes make the schema invalid (`422 INVALID_SCHEMA`) with messages such as `Unknown question "intent".`, `"route" has no option "buyer".` or `Pick the option whose probability counts.`
## How the value is computed [#how-the-value-is-computed]
```text
composite = Σ(weight × term value) / Σ|weight|
```
Every term contributes a value between 0 and 1:
| Question type | Term value |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `probability` | the probability of *yes* — the answer itself |
| `score` | the normalized weighted level, `(level − 1) / (levels − 1)`: 0 at the first level, 1 at the last. The weighted level is the one returned in [`scores`](https://docs.dcision.io/docs/concepts/questions#score) |
| `choice` | the probability of `option` in the question's distribution |
* With positive weights the composite is between **0 and 1**; with negative weights, between **−1 and 1**.
* It is rounded to 4 decimals.
* Terms without a usable answer are skipped, and a composite with no usable term is left out of the response.
For the lead above — `purchase_intent` 0.9412, `priority` at weighted level 2.81 on a 4-level scale, `route` = `sales` with probability 0.88:
```text
priority term = (2.81 − 1) / (4 − 1) = 0.6033
lead_score = (2 × 0.9412 + 1 × 0.6033 + 1 × 0.88) / (2 + 1 + 1) = 0.8414
```
## In the response [#in-the-response]
The response gains a `composites` object, keyed by your composite keys, whenever the decision defines composites:
```json
{
"result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
"scores": { "priority": 2.81 },
"composites": { "lead_score": 0.8414 },
"action": "continue"
}
```
The [Playground](https://docs.dcision.io/docs/concepts/playground) shows composites in their own card, the editor's **API contract** lists them as numbers, and [executions](https://docs.dcision.io/docs/concepts/executions-and-usage) store them when `storeOutput` is on.
## In policies [#in-policies]
A policy rule can compare a composite with a number, with any of the six operators — `on` stays `output`, the default:
```json
{ "field": "lead_score", "operator": "gte", "value": 0.75, "action": "continue" }
```
Composites combine with `and` conditions like any other field. The [Resume Screening](https://docs.dcision.io/docs/patterns/composite-scoring) template blocks a candidate only when **both** role fits are low:
```json
{
"field": "senior_ic",
"on": "output",
"operator": "lt",
"value": 0.35,
"and": [{ "field": "eng_manager", "on": "output", "operator": "lt", "value": 0.35 }],
"action": "block"
}
```
See [Policies and actions](https://docs.dcision.io/docs/concepts/policies-and-actions).
## In the editor [#in-the-editor]
In the decision's **Editor** tab, the **Composites** card sits between Questions and Policies:
1. Click **Add composite** and name it — for example `lead_score` — with an optional description.
2. Each term reads *weight × question*: set the weight and pick the question. For a choice question, also pick the option, shown as `p(option)`; score terms say *normalized level* and probability terms *p(yes)*.
3. **Add term** for each dimension, up to 20.
Composites then appear under **Composites** in the field list of every policy rule. Renaming a composite updates the rules that start with it; after removing one, review the **Policies** card — conditions that still point at it are flagged as issues to fix. The visual builder doesn't show composites yet — edit them in the Editor tab.
## Tips [#tips]
* **One dimension per question.** A composite is only as good as its terms: atomic, literal questions — see [Writing good questions](https://docs.dcision.io/docs/guides/writing-good-questions).
* **Tune weights, not wording.** Weights change the ranking without touching what the engine reads, and each change ships as a new version.
* **Several views of the same answers.** Two composites over the same questions — Senior IC and Engineering Manager fit — cost nothing extra: no new question, no new engine call.
* **Use negative weights for disqualifiers**, for example `lead_score = 2 × purchase_intent + 1 × priority − 3 × spam`.
* **Threshold, don't interpolate.** Weighted levels are good for "above or below" decisions; don't use them to reconstruct an exact number between two levels.
---
# Policies and actions
> How policy rules with AND conditions are written and evaluated, and how the action and action_reason of every response are computed — policy, other option, low confidence, default or engine error.
Source: https://docs.dcision.io/docs/concepts/policies-and-actions
Questions produce answers; **policies** turn answers into an **action** your code can switch on. Every successful response carries both the `action` and the `action_reason` that explains it.
## Actions [#actions]
| Action | Meaning by convention |
| ---------- | ----------------------------------------------------------------------- |
| `continue` | Proceed with the automated path. |
| `block` | Stop: reject, drop or quarantine the event. |
| `escalate` | Hand over to a person or a higher tier. |
| `fallback` | Take your fallback path — for example a larger model or more retrieval. |
Actions are **labels for your code**. Dcision never blocks or reroutes anything by itself: a decision whose action is `block` still answers `200 OK`, and your application applies the action.
## Policy rules [#policy-rules]
A rule is one condition, optional `and` conditions, and the action to return when **all** of them hold:
```json
{ "field": "route", "on": "output", "operator": "eq", "value": "spam", "action": "block" }
```
| Property | Type | Default | Description |
| ---------- | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `field` | string | — | The key of a question — or of a [composite](https://docs.dcision.io/docs/concepts/composites) — in this decision. |
| `on` | string | `output` | What to compare: the answer (`output`), its `confidence`, or the weighted level of a score question (`score`). |
| `operator` | string | — | `eq`, `neq`, `gt`, `gte`, `lt` or `lte`. |
| `value` | string or number | — | The value to compare with (strings up to 255 characters). |
| `and` | array | — | Up to 5 more conditions — each with its own `field`, `on`, `operator` and `value` — that must also hold. |
| `action` | string | — | `continue`, `block`, `escalate` or `fallback`. |
A decision has up to **50 rules**. What `value` and `operator` accept depends on the field and on `on`:
| Field | `on` | Compares | `value` | Operators |
| -------------------- | ------------ | ---------------------------------------------------------------------- | ------------------------------- | ----------- |
| choice question | `output` | the chosen option (case and surrounding spaces ignored) | an option, as a string | `eq`, `neq` |
| score question | `output` | the most likely level (1 = lowest) | a level number or a level label | all six |
| score question | `score` | the weighted level, from 1 — it can fall between levels, such as 2.4 | a number | all six |
| probability question | `output` | the probability of *yes* | a number from 0 to 1 | all six |
| any question | `confidence` | the answer's [confidence](https://docs.dcision.io/docs/concepts/confidence-and-probabilities) | a number from 0 to 1 | all six |
| composite | `output` | the composite's value | a number | all six |
Rules that break these constraints make the schema invalid (`422 INVALID_SCHEMA`), for example `"Choice outputs only support eq / neq."`, `"Unknown question \"intent\"."`, `"Only score questions have a weighted level."` or `"Compare a composite's value with a number."`.
## AND conditions [#and-conditions]
Compound rules are what most [patterns](https://docs.dcision.io/docs/patterns) need. Every condition of a rule must hold for the rule to match. From the templates:
| Template | Rule |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| [`ticket-triage`](https://docs.dcision.io/docs/patterns/fan-out) | `category` = `bug_report` **AND** `bug_severity` ≥ 4 → `escalate` |
| [`voice-banking`](https://docs.dcision.io/docs/patterns/confidence-routing) | `intent` = `approve_transfer` **AND** confidence of `intent` \< 0.9 → `escalate` |
| [`customer-service-router`](https://docs.dcision.io/docs/patterns/intent-routing) | `intent` = `technical_issue` **AND** weighted level of `complexity` ≥ 2.5 → `escalate` |
| [`resume-screening`](https://docs.dcision.io/docs/patterns/composite-scoring) | `senior_ic` \< 0.35 **AND** `eng_manager` \< 0.35 → `block` |
As JSON, the first one reads:
```json
{
"field": "category",
"on": "output",
"operator": "eq",
"value": "bug_report",
"and": [{ "field": "bug_severity", "on": "output", "operator": "gte", "value": 4 }],
"action": "escalate"
}
```
Conditions of one rule can use different questions and composites, or the same question twice — its answer **and** its confidence, as in `voice-banking`. For OR, write two rules with the same action.
## How the action is computed [#how-the-action-is-computed]
After the engine answers, Dcision computes the [composites](https://docs.dcision.io/docs/concepts/composites), then walks four steps and stops at the first that applies:
**Policies, in order.** The first rule whose conditions all hold sets the action.
`action_reason` → `{ "type": "policy", "rule": 1 }` — `rule` is the **0-based index** of the rule (the app shows it as "Policy rule #2").
**A choice answered `other`.** The first choice question, in question order, whose answer is the reserved `other` option triggers the decision's **fallback action** (`settings.fallbackAction`, `escalate` by default).
`action_reason` → `{ "type": "other_option", "question": "route" }`
**Low confidence.** The first question, in question order, whose confidence is below its `minConfidence` triggers the fallback action.
`action_reason` → `{ "type": "low_confidence", "question": "route", "confidence": 0.52, "minConfidence": 0.8 }`
**Default.** Nothing applied: the action is `continue`.
`action_reason` → `{ "type": "default" }`
Policies always win: a rule such as `route eq other → block` takes precedence over the fallback action.
A fifth reason exists outside these steps. When the engine fails and the decision's [`onEngineError`](https://docs.dcision.io/docs/concepts/settings#onengineerror) is `fallback`, there are no answers to evaluate: the response carries the fallback action and `{ "type": "engine_error", "code": "ENGINE_TIMEOUT" }`.
## Worked example [#worked-example]
The [Lead Qualification](https://docs.dcision.io/docs/guides/lead-qualification) template has two rules and the default fallback action, `escalate`:
```json
[
{ "field": "route", "on": "output", "operator": "eq", "value": "spam", "action": "block" },
{ "field": "purchase_intent", "on": "confidence", "operator": "lt", "value": 0.6, "action": "escalate" }
]
```
| `result` | `confidence.purchase_intent` | `action` | `action_reason` |
| ------------------------------------------- | ---------------------------- | ---------- | ------------------------------------------------- |
| `route: "sales"`, `purchase_intent: 0.9412` | 0.8824 | `continue` | `{ "type": "default" }` |
| `route: "spam"`, `purchase_intent: 0.03` | 0.94 | `block` | `{ "type": "policy", "rule": 0 }` |
| `route: "sdr"`, `purchase_intent: 0.45` | 0.1 | `escalate` | `{ "type": "policy", "rule": 1 }` |
| `route: "other"`, `purchase_intent: 0.15` | 0.7 | `escalate` | `{ "type": "other_option", "question": "route" }` |
The confidence of a probability is `|2p − 1|`, so rule 1 escalates every lead whose `purchase_intent` falls between 0.2 and 0.8.
## In the editor [#in-the-editor]
The **Policies** card reads each rule as *IF condition THEN action*:
1. **Add rule** creates a rule on the first question.
2. Pick the field — questions, then composites — and what to compare: *answer*, *confidence* or, for a score question, *weighted level*. Then the operator and the value.
3. **+ AND** adds a condition, up to 5. Each one has its own field, comparison, operator and value.
4. Pick the action after **THEN**.
Rules are evaluated top to bottom. Renaming or deleting a question updates or removes the rules that start with it; `and` conditions and composite terms that point at a question that no longer exists are flagged as issues to fix.
## Acting on the result [#acting-on-the-result]
```js
switch (decision.action) {
case "continue":
return routeTo(decision.result.route);
case "block":
return discard(lead);
case "escalate":
return sendToHuman(lead, decision.action_reason);
case "fallback":
return askLargerModel(lead);
}
```
By default, when the engine times out or fails the API returns an error such as `504 ENGINE_TIMEOUT` and **no** action: decide in your code what an error means — usually the same path as `escalate`. Set `onEngineError` to `fallback` to get a `200` with the fallback action and `action_reason.type = "engine_error"` instead; those answers aren't billed. See [Decision settings](https://docs.dcision.io/docs/concepts/settings#onengineerror) and [Errors](https://docs.dcision.io/docs/api/errors).
## Tips [#tips]
* Put the most specific rules first: the first match wins. Compound (`and`) rules usually come before the simple ones they refine.
* Use `on: "confidence"` with `lt` to escalate answers the engine isn't sure about — or set `minConfidence` on the question to use the fallback action instead. Combine both for [per-action thresholds](https://docs.dcision.io/docs/patterns/confidence-routing).
* Use `on: "score"` to act between levels — `frustration ≥ 3.5` — instead of waiting for the top level to win.
* Keep `block` for clear-cut outputs (spam, abuse) and let uncertainty `escalate`.
* Policies are part of the schema: a change takes effect for API clients only when you deploy a new version.
---
# Confidence and probabilities
> What confidence means for each question type on one 0–1 scale, how it affects minConfidence, weighted levels, the full probability distributions and near ties.
Source: https://docs.dcision.io/docs/concepts/confidence-and-probabilities
The engine doesn't just pick an answer: it returns a probability distribution for every question. Dcision turns it into a **confidence** per question — always — and can return the **full distribution** on request.
## Calibrated probabilities [#calibrated-probabilities]
Jev is trained with RLCD — *reinforcement learning for calibrated decisions* — to return probabilities that mean what they say. Across many predictions of a well-calibrated model, outcomes given a probability of 0.2 happen about 20% of the time, and outcomes given 0.8 about 80% of the time. That is a property of groups of answers, not a guarantee about any single one — which is exactly what makes thresholds, escalation and auditing work.
## Confidence [#confidence]
`confidence` has one number from 0 to 1 per question, keyed like `result`. It follows TypeSafe's [definition](https://docs.typesafe.ai/confidence), so every question type shares **one scale**: **1** when all the probability is on one answer, **0** when it is spread evenly or the answer is a coin flip.
| Question type | Confidence is… | Formula |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `choice` | how far the chosen option stands above an even split between all `n` options, `other` included | `(p_max − 1/n) / (1 − 1/n)` |
| `score` | how concentrated the distribution is around the most likely level — probability on a neighboring level lowers it less than probability far away | `1 − spread / spread of an even distribution`, floored at 0 |
| `probability` | the distance from a coin flip | `\|2p − 1\|` |
* For choice and score questions, Dcision uses the confidence the engine reports, computed with these formulas; it applies them itself only if the engine omits it.
* Examples: a choice with five options and `p_max = 0.88` has confidence `0.85`. A probability of `0.9412` has confidence `0.8824`; `0.03` has `0.94`; `0.5` has `0`.
* For score questions, `spread` is the probability-weighted distance, in levels, from the most likely level. With three levels, `(0, 0.5, 0.5)` — torn between neighbors — has confidence 0.25, while `(0.5, 0, 0.5)` — torn between opposite ends — has confidence 0.
Use confidence in [policies](https://docs.dcision.io/docs/concepts/policies-and-actions) (`"on": "confidence"`) or with a question's [`minConfidence`](https://docs.dcision.io/docs/concepts/questions#minimum-confidence) to route uncertain answers to a person — see [Confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing).
## Minimum confidence on probability questions [#minimum-confidence-on-probability-questions]
Because the confidence of a probability is `|2p − 1|`, a threshold on it flags a **band of probabilities around 0.5**: an answer is below a minimum confidence `c` when `p` falls strictly between `(1 − c) / 2` and `(1 + c) / 2`.
| `minConfidence` | Flags probabilities between |
| --------------- | --------------------------- |
| 0.5 | 0.25 and 0.75 |
| 0.6 | 0.2 and 0.8 |
| 0.8 | 0.1 and 0.9 |
| 0.9 | 0.05 and 0.95 |
The same holds for rules with `"on": "confidence"`: `purchase_intent` confidence `lt 0.6` matches every `p` between 0.2 and 0.8. The editor shows the band next to the **Minimum confidence** slider of probability questions.
Until v0.2 (October 5, 2026), the confidence of a probability was `max(p, 1 − p)`. The new value is lower for the same answer — `p = 0.8` had confidence 0.8 and now has 0.6. To keep the behavior of an old threshold `t`, use `2t − 1`: an old `0.8` becomes `0.6`. Choice and score questions still use the engine's own confidence; when a provider omits it, Dcision now applies the formulas above instead of the top probability. Review `minConfidence` and confidence rules on probability questions in the Playground, then deploy a new version.
## Weighted levels [#weighted-levels]
For every score question, the response also has `scores`: the **probability-weighted level**, counted from 1. With the distribution `low 0.02 · medium 0.21 · high 0.71 · critical 0.06`, the most likely level is `high` (3), and the weighted level is `1 × 0.02 + 2 × 0.21 + 3 × 0.71 + 4 × 0.06 = 2.81`.
```json
{
"result": { "priority": "high" },
"confidence": { "priority": 0.69 },
"scores": { "priority": 2.81 }
}
```
* Use it in rules with `"on": "score"` to act between levels — `priority ≥ 3.5` fires when the weight leans toward `critical`.
* [Composites](https://docs.dcision.io/docs/concepts/composites) use it, normalized to 0–1, as the value of a score term.
* Use it for thresholds and ranking, not to reconstruct an exact quantity: score levels are categories, not a ruler.
## Full distributions [#full-distributions]
Add `include=probabilities` to the query string:
```bash
curl -X POST "https://api.dcision.io/v1/decisions/lead-qualification?include=probabilities" \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "state": { "message": "We need pricing for 500 users and want to start next month.", "company_size": 500 } }'
```
The response gains a `probabilities` object, between `action_reason` and `metrics`:
```json
{
"result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
"confidence": { "purchase_intent": 0.8824, "priority": 0.69, "route": 0.85 },
"scores": { "priority": 2.81 },
"composites": { "lead_score": 0.8414 },
"action": "continue",
"action_reason": { "type": "default" },
"probabilities": {
"purchase_intent": { "yes": 0.9412, "no": 0.0588 },
"priority": { "low": 0.02, "medium": 0.21, "high": 0.71, "critical": 0.06 },
"route": { "sales": 0.88, "sdr": 0.09, "nurture": 0.02, "spam": 0, "other": 0.01 }
}
}
```
| Question type | Distribution |
| ------------- | --------------------------------------------------- |
| `choice` | one entry per option, in option order, `other` last |
| `score` | one entry per label, from lowest to highest |
| `probability` | `{ "yes": p, "no": 1 − p }` |
All values are rounded to 4 decimals. `include` accepts a comma-separated list; `probabilities` is the only value recognized today. Distributions don't change the price of a call.
The [Playground](https://docs.dcision.io/docs/concepts/playground) always shows distributions, and [executions](https://docs.dcision.io/docs/concepts/executions-and-usage) store them when `storeOutput` is on.
## Near ties [#near-ties]
Two options are a **near tie** when the runner-up has more than 20% probability and trails the top option by less than 15 points — for example `{ "sales": 0.46, "sdr": 0.40 }`. The Playground flags it:
> Near tie: the top options are close. Make the option descriptions more distinct (add exclusions).
A near tie usually means two option descriptions overlap. Rewrite them as mutually exclusive criteria ("some intent, **but no budget or timeline yet**") and test again — the comparison with the previous run shows whether the answer moved.
## Using distributions in your code [#using-distributions-in-your-code]
* **Second best.** When the action is `escalate`, show the reviewer the top two options with their probabilities.
* **Your own measure.** The distribution is all you need to compute another statistic — the top probability, or the ratio between the top two options — when it suits your decision better.
* **Calibrate thresholds.** Compare the confidence of past runs — in **Executions** or through [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions) — with what really happened before choosing a `minConfidence`.
---
# Versions and deploy
> Drafts, immutable versions, deploys, slugs, disabling, duplicating and deleting decisions — and what your API clients see at each step.
Source: https://docs.dcision.io/docs/concepts/versions-and-deploy
Every decision has an editable **draft** and a history of immutable **versions**. The API always runs the **active version**; the draft only reaches production when you deploy it.
## Anatomy of a decision [#anatomy-of-a-decision]
| Property | Rules |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Name | 1 to 80 characters. |
| Slug | The endpoint: `POST /v1/decisions/{slug}`. Lowercase letters, digits and single dashes, up to 64 characters. Unique in the workspace. |
| Description | Optional, up to 500 characters. |
| Draft | The schema you are editing. |
| Versions | Immutable snapshots created by each deploy: v1, v2, … |
| Status | `Draft`, `Live` or `Disabled`. |
## Lifecycle [#lifecycle]
```text
Draft ──deploy──▶ Live (v1) ──deploy──▶ Live (v2) ──deploy──▶ …
│ ▲
disable│ │enable
▼ │
Disabled
```
| Status | What `POST /v1/decisions/{slug}` returns |
| ------------------------- | ----------------------------------------------------------------- |
| Draft — never deployed | `409 DECISION_NOT_DEPLOYED` |
| Live | `200` with the active version's answer; `version` tells which one |
| Disabled | `409 DECISION_DISABLED` |
| Deleted (or unknown slug) | `404 DECISION_NOT_FOUND` |
## Drafts and auto-save [#drafts-and-auto-save]
The editor and the visual builder edit the same draft. Changes are saved automatically about 1.5 seconds after your last edit — only when the draft is valid; otherwise the save bar lists the issues with a *Go to error* link.
If a teammate (or another tab) saved the decision after you opened it, your save is refused rather than overwriting theirs: the save bar shows *Someone else saved this decision* and offers **Load current version**.
Saving a draft never changes production.
## Deploy [#deploy]
**Deploy** (in the decision's **Deploy** tab) snapshots the current draft as version *n + 1* and makes it active **immediately** — in-flight clients switch to it on their next call. Unsaved edits are saved first; a draft with schema issues can't be deployed.
* Versions are **immutable**: they can't be edited or deleted.
* The response's `version` field and every execution record the version that answered.
* The **Version history** lists all versions (*Live* or *Archived*) with their full schema.
* A version keeps running as it was deployed. When Dcision tightens a validation rule — v0.3 checks rule values against the options, levels and ranges they compare with — the new rule applies the next time you save or deploy the draft, never to a live version.
* Deploying, like every change to a decision, needs the Member role or higher — see [Team, roles and account](https://docs.dcision.io/docs/team-and-roles).
There is no one-click rollback yet. To return to an earlier behavior, open the version's schema in **Version history**, bring the draft back to it and deploy — this creates a new version number with the old logic.
## Slugs [#slugs]
* When you create a decision, the slug is generated from its name: accents removed, lowercase, anything else turned into dashes — "Lead Qualification!" becomes `lead-qualification`. If the slug is taken, a suffix is added: `lead-qualification-2`.
* You can change the slug until the **first deploy**. After that it is **locked**: clients call it, and a rename would break them (`409 CONFLICT` — "The slug can't change after the first deploy.").
* Slugs are unique per workspace. Two workspaces can both have a `lead-qualification` decision: the API key decides which workspace — and so which decision — runs.
## Disable and enable [#disable-and-enable]
**Disable endpoint** stops a live decision without losing anything: calls get `409 DECISION_DISABLED` until you click **Enable endpoint**, which serves the same active version again. Only a decision that has been deployed can be enabled.
## Duplicate [#duplicate]
**Duplicate** copies the **draft** (not the versions) into a new decision named "*name* (copy)", with its own slug and no versions. It counts toward your plan's decision limit.
## Delete [#delete]
**Delete** removes the decision together with its versions and its execution history. Calls to its slug fail immediately with `404 DECISION_NOT_FOUND`, and the slug becomes free for a new decision. This can't be undone.
## Plan limits [#plan-limits]
The number of decisions per workspace depends on the plan — 2 on Genesis, 10 on Developer, unlimited on Growth and Enterprise. Creating or duplicating beyond it fails with `402 PLAN_LIMIT_REACHED`; delete a decision or [upgrade](https://docs.dcision.io/docs/plans-and-billing). Disabled and draft decisions count too.
---
# Decision settings
> engine, storeInput, storeOutput, timeoutMs, fallbackAction and onEngineError — what each runtime setting of a decision does, its default and its limits — plus the workspace settings.
Source: https://docs.dcision.io/docs/concepts/settings
Each decision has six runtime settings, edited in the **Engine & runtime** card of the editor (or the *Engine* node of the visual builder). They are part of the schema, so API clients get a change when you **deploy** it; the Playground uses the draft's settings right away.
```json
"settings": {
"storeInput": true,
"storeOutput": true,
"timeoutMs": 5000,
"fallbackAction": "escalate",
"onEngineError": "error",
"engine": "laya"
}
```
| Setting | Type | Default | What it does |
| ---------------- | ------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `storeInput` | boolean | `true` | Keep the request's `state` in the execution log. |
| `storeOutput` | boolean | `true` | Keep `result`, `confidence`, weighted levels, composites and the full distributions in the execution log. |
| `timeoutMs` | integer, 500–30,000 | `5000` | The engine deadline for one call of this decision, retries included, in milliseconds. |
| `fallbackAction` | `continue`, `block`, `escalate`, `fallback` | `escalate` | The action when a choice answers `other` or a question is below its `minConfidence` and no policy matched — and, with `onEngineError: "fallback"`, when the engine fails. |
| `onEngineError` | `error`, `fallback` | `error` | What the API answers when the engine fails: the error, or a `200` with the fallback action. |
| `engine` | `jev`, `laya` | absent | The engine that runs this decision. Absent = the workspace's default engine. See [Engines and BYOK](https://docs.dcision.io/docs/concepts/engines-and-byok). |
## engine [#engine]
Leave it out to follow the workspace's **default engine** (Settings → Engine) — changing the default then moves every such decision at once. Set it to pin one decision to an engine: for example keep the workspace on Jev and run a multilingual triage decision on Laya. In the editor: **Engine & runtime → Engine**.
The engine must be set up in Settings → Engine — for Laya, a Laya server — otherwise runs answer `424 ENGINE_NOT_CONFIGURED`. Re-test thresholds such as `minConfidence` in the Playground when you switch engines: each model has its own confidence profile.
## storeInput and storeOutput [#storeinput-and-storeoutput]
These settings only affect what is **logged**; the API response is always complete.
* With `storeInput` off, executions show *Not stored (storeInput is off)* instead of the state, and the run can't be replayed in the Playground. Invalid states are not stored either.
* With `storeOutput` off, executions keep the status, action, action reason, latency, model, tokens and estimated cost, but not the answers, confidence, weighted levels, composites or distributions.
Turn them off for decisions that receive personal or sensitive data. See [Security and data](https://docs.dcision.io/docs/security).
When a request carries an `Idempotency-Key`, its response — answers included — is kept for 24 hours so a retry can be replayed, whatever `storeOutput` says. See [Idempotency](https://docs.dcision.io/docs/api/idempotency).
## timeoutMs [#timeoutms]
The deadline for the engine, from 500 ms to 30 s — 5 seconds by default. It covers the whole engine call, **retries included**:
* Dcision makes **up to 2 retries** on network errors, `429`, `503`, `529` and other `5xx` answers from the provider, waiting 200 ms and then 400 ms — or the provider's `Retry-After` when it sends one and it fits in the deadline.
* A timeout isn't retried, and neither is a `4xx` other than `429`.
* On Dcision's shared engine key, bursts wait for a free slot inside the same deadline instead of failing at once.
A call that runs out of time fails with `504 ENGINE_TIMEOUT` — or answers with the fallback action when `onEngineError` is `fallback`. Raise `timeoutMs` for large states or many questions; lower it on latency-critical paths that have a fallback. Give your own HTTP client a timeout a few seconds longer than `timeoutMs`.
## fallbackAction [#fallbackaction]
The action applied when no policy matched and either a choice question answered the reserved `other` option or a question's confidence fell below its `minConfidence`. The default, `escalate`, sends uncertain cases to a person; `continue` effectively ignores uncertainty. See [Policies and actions](https://docs.dcision.io/docs/concepts/policies-and-actions).
The fallback action is also what `onEngineError: "fallback"` answers when the engine fails.
## onEngineError [#onengineerror]
What happens when the engine fails — a timeout, an overloaded or unreachable provider, a rejected key, an answer outside the contract — after the retries:
| Value | The API answers |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `error` (default) | The error, such as `504 ENGINE_TIMEOUT` or `503 ENGINE_UNAVAILABLE`. Your code decides what it means. |
| `fallback` | `200 OK` with `action` = the decision's `fallbackAction`, `action_reason` = `{ "type": "engine_error", "code": "" }`, an empty `result` and `confidence`, and zero tokens and cost. |
```json title="200 OK with onEngineError: fallback"
{
"decision_id": "dec_3fKq9ZtW1mXcV7bN2pLa",
"execution_id": "exec_Vn4Kp8Wd2Lq6Tz1Xc9Bs",
"schema": "lead-qualification",
"version": 3,
"result": {},
"confidence": {},
"action": "escalate",
"action_reason": { "type": "engine_error", "code": "ENGINE_TIMEOUT" },
"metrics": {
"latency_ms": 5004,
"engine": "jev",
"model": "jev-1.13.0",
"estimated_cost_usd": 0,
"input_tokens": 0,
"output_tokens": 0
}
}
```
* These answers are **not billed** and **not stored for idempotency**: retrying with the same `Idempotency-Key` reaches the engine again.
* The run is recorded as an execution with status *Error* and the engine's error code.
* It covers engine failures only (the `ENGINE_*` codes except `ENGINE_NOT_CONFIGURED`). Invalid states, quota, rate limits and a missing engine key still answer with their errors.
Choose `fallback` for paths that must always answer — a chat or a voice assistant, a checkout — and pick a `fallbackAction` that is safe without answers, usually `escalate`. In the editor: **Engine & runtime → If the engine fails → Answer with the fallback action**.
## Workspace settings [#workspace-settings]
**Settings** in the app holds what applies to every decision of the workspace. Everyone in the workspace can open it; **Admins and the Owner** change it, and the Danger zone belongs to the Owner alone — see [Team, roles and account](https://docs.dcision.io/docs/team-and-roles).
| Setting | Who changes it | Description |
| ----------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Name | Admins and the Owner | The workspace name, 2 to 80 characters. The workspace slug is fixed. |
| Execution log retention | Admins and the Owner | 7, 14, 30, 90, 180 or 365 days. Your plan caps it: logs are kept for the **shorter** of this value and the plan's retention — see [Executions and usage](https://docs.dcision.io/docs/concepts/executions-and-usage#retention). |
| Engine | Admins and the Owner | The default engine — Jev or Laya — and how each one is called: Dcision's key or server, or your own provider key or Laya server, with the model. See [Engines and BYOK](https://docs.dcision.io/docs/concepts/engines-and-byok). Applies to new runs immediately, for every decision that doesn't pick its own engine. |
| Organization | Admins and the Owner | Legal name, tax ID, billing e-mail, country, address and website — printed on invoices. |
| Members | Admins for Members and Viewers; the Owner for Admins | Who is in the workspace and their role — Owner, Admin, Member or Viewer — plus invitations. See [Team, roles and account](https://docs.dcision.io/docs/team-and-roles). |
| Destinations | Admins and the Owner | The signing secret of deliveries and the [workspace secrets](https://docs.dcision.io/docs/destinations/secrets) that destinations use. |
| Danger zone | The Owner | Delete the workspace. |
---
# Templates
> The nine built-in templates — what each one decides, its questions, composites and policies, the patterns it demonstrates — and how to start a decision from one.
Source: https://docs.dcision.io/docs/concepts/templates
Templates are production-ready starting points. Using one creates an **editable draft** in your workspace with the template's name, description and schema; nothing is shared with the template afterwards.
## Start from a template [#start-from-a-template]
* **Templates** in the app → pick a card → **Create decision**, or
* **Decisions → New decision** → choose a template (or **Blank decision**) → **Create decision**.
The **Templates** page opens with the four [patterns](https://docs.dcision.io/docs/patterns) — each card explains the pattern and links to the templates that implement it — followed by **All templates**, with each template's category, patterns and questions.
The slug — your endpoint — comes from the decision's name: *Lead Qualification* becomes `lead-qualification` (or `lead-qualification-2` if it's taken). The four pattern templates have the pattern in their name — *Support Ticket Triage (fan-out)* would become `support-ticket-triage-fan-out` — so rename them in **Name it** to get a short slug such as `ticket-triage`, or edit the slug in the editor before the first deploy. When the slug matches a template id, the sidebar's **Playground** starts with the template's sample state.
With the [CLI](https://docs.dcision.io/docs/cli), `dcision templates` lists the template ids and `dcision init --template ` writes the schema to a file.
## The templates [#the-templates]
| Id | Category | Patterns | Decides | State |
| ------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------- |
| `lead-qualification` | Sales | [Intent routing](https://docs.dcision.io/docs/patterns/intent-routing), [Confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing), [Composite scoring](https://docs.dcision.io/docs/patterns/composite-scoring) | Purchase intent, priority, the team that gets an inbound lead and a lead score | `message`\*, `company_size`, `source` |
| `support-routing` | Support | [Intent routing](https://docs.dcision.io/docs/patterns/intent-routing), [Confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing) | Department, urgency and whether a human is needed | `message`\*, `plan` |
| `spam-detection` | Trust & safety | [Intent routing](https://docs.dcision.io/docs/patterns/intent-routing) | Spam probability and allow / review / block | text |
| `agent-routing` | AI agents | [Intent routing](https://docs.dcision.io/docs/patterns/intent-routing) | The agent's first tool and whether the task needs an LLM | `task`\* |
| `rag-relevance` | RAG | — | Whether a retrieved chunk answers the query, and whether to retrieve more | `query`\*, `chunk`\* |
| `ticket-triage` | Support | [Speculative fan-out](https://docs.dcision.io/docs/patterns/fan-out), [Intent routing](https://docs.dcision.io/docs/patterns/intent-routing) | Category, bug severity, repro steps, refund and frustration in one call | `subject`, `body`\* |
| `voice-banking` | Finance | [Confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing), [Intent routing](https://docs.dcision.io/docs/patterns/intent-routing) | A spoken banking command, with more confidence required for risky actions | text |
| `resume-screening` | Hiring | [Composite scoring](https://docs.dcision.io/docs/patterns/composite-scoring) | Four skill levels combined into Senior IC and Engineering Manager fit | `resume`\* |
| `customer-service-router` | Support | [Intent routing](https://docs.dcision.io/docs/patterns/intent-routing), [Confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing) | The intent and complexity of a message, to route it to code, an LLM or a person | text |
\* required field.
### Lead qualification [#lead-qualification]
| Question | Type | Answers |
| ----------------- | ----------- | ----------------------------------------------------------------------------------------------- |
| `purchase_intent` | probability | Real intent to buy in the next 30 days (yes: asks for pricing, a demo, a quote or a start date) |
| `priority` | score | `low`, `medium`, `high`, `critical` |
| `route` | choice | `sales`, `sdr`, `nurture`, `spam`, `other` |
Composite: `lead_score` = 2 × `purchase_intent` + 1 × `priority` + 1 × p(`route` = `sales`), from 0 to 1. Policies: `route` = `spam` → `block`; confidence of `purchase_intent` below 0.6 → `escalate`. [Guide](https://docs.dcision.io/docs/guides/lead-qualification).
### Support routing [#support-routing]
| Question | Type | Answers |
| ---------------- | ----------- | ---------------------------------------------------------------------------- |
| `department` | choice | `billing`, `technical`, `account`, `sales`, `other` — minimum confidence 0.6 |
| `urgency` | score | `low`, `normal`, `high`, `urgent` |
| `human_required` | probability | Needs a human (yes: angry customer, legal threat, data loss, cancellation) |
Policy: `human_required` ≥ 0.7 → `escalate`. A `department` below 60% confidence takes the fallback action. [Guide](https://docs.dcision.io/docs/guides/support-routing).
### Spam detection [#spam-detection]
| Question | Type | Answers |
| ------------------ | ----------- | ------------------------------------------ |
| `spam_probability` | probability | Spam, phishing or an unsolicited promotion |
| `action` | choice | `allow`, `review`, `block`, `other` |
Policy: `action` = `block` → `block`. The state is plain text. [Guide](https://docs.dcision.io/docs/guides/spam-detection).
### Agent routing [#agent-routing]
| Question | Type | Answers |
| ----------------- | ----------- | --------------------------------------------------------------- |
| `selected_tool` | choice | `search`, `database`, `calculator`, `none`, `other` |
| `needs_reasoning` | probability | The task needs multi-step reasoning from a large language model |
Policy: `needs_reasoning` ≥ 0.8 → `fallback`. [Guide](https://docs.dcision.io/docs/guides/agent-routing).
### RAG relevance [#rag-relevance]
| Question | Type | Answers |
| --------------- | ----------- | -------------------------------------------------------- |
| `relevant` | probability | The chunk helps answer the query |
| `relevance` | score | `none`, `partial`, `direct` |
| `retrieve_more` | probability | The system should retrieve more context before answering |
No policies: every answer returns `continue`. [Guide](https://docs.dcision.io/docs/guides/rag-relevance).
### Support ticket triage [#support-ticket-triage]
| Question | Type | Answers |
| ------------------------ | ----------- | --------------------------------------------------------------------------- |
| `category` | choice | `bug_report`, `billing`, `feature_request`, `how_to`, `other` |
| `bug_severity` | score | `cosmetic`, `minor`, `major`, `critical` (a structured level with a rubric) |
| `has_reproducible_steps` | probability | The ticket includes steps to reproduce the problem |
| `refund_requested` | probability | The customer explicitly asks for a refund or credit |
| `frustration` | score | `calm`, `annoyed`, `frustrated`, `very angry` |
Policies: `category` = `bug_report` **and** `bug_severity` ≥ 4 → `escalate`; `refund_requested` ≥ 0.8 → `escalate`; weighted level of `frustration` ≥ 3.5 → `escalate`. [Pattern: Speculative fan-out](https://docs.dcision.io/docs/patterns/fan-out).
### Voice banking commands [#voice-banking-commands]
| Question | Type | Answers |
| -------- | ------ | ----------------------------------------------------------------------------------------------------- |
| `intent` | choice | `check_balance`, `transfer_money`, `approve_transfer`, `block_card`, `other` — minimum confidence 0.6 |
Policies: `intent` = `approve_transfer` **and** its confidence below 0.9 → `escalate`; `intent` = `transfer_money` **and** its confidence below 0.8 → `escalate`. The state is plain text. [Pattern: Confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing).
### Resume screening [#resume-screening]
| Question | Type | Answers |
| ----------------- | ----- | ----------------------------------------------------------------- |
| `python_depth` | score | `none`, `basic`, `working`, `strong`, `expert` |
| `team_leadership` | score | `none`, `informal`, `tech lead`, `manager`, `manager of managers` |
| `system_design` | score | `none`, `basic`, `working`, `strong`, `expert` |
| `generalist` | score | `narrow`, `some`, `broad`, `very broad`, `full stack and ops` |
Composites: `senior_ic` (0.4 × `python_depth`, 0.1 × `team_leadership`, 0.4 × `system_design`, 0.1 × `generalist`) and `eng_manager` (0.15, 0.4, 0.2, 0.25). Policies: either fit ≥ 0.7 → `continue`; both below 0.35 → `block`; otherwise → `escalate`. [Pattern: Composite scoring](https://docs.dcision.io/docs/patterns/composite-scoring).
### Customer service router [#customer-service-router]
| Question | Type | Answers |
| ------------ | ------ | --------------------------------------------------------------------------- |
| `intent` | choice | `order_status`, `billing_question`, `technical_issue`, `complaint`, `other` |
| `complexity` | score | `simple`, `moderate`, `complex` |
Policies: `intent` = `complaint` → `escalate`; `intent` = `technical_issue` **and** weighted level of `complexity` ≥ 2.5 → `escalate`; confidence of `complexity` below 0.5 → `escalate`. The state is plain text. [Pattern: Intent routing](https://docs.dcision.io/docs/patterns/intent-routing).
## Blank decision [#blank-decision]
**Blank decision** starts with one required `message` string field and one choice question, `intent`, with the options `buy` ("wants to buy or get pricing"), `support` ("needs help with an existing product") and `other`.
All templates and the blank decision use the default [settings](https://docs.dcision.io/docs/concepts/settings): input and output stored, 5,000 ms timeout, `escalate` as the fallback action and engine errors returned as errors.
---
# Engines and BYOK
> Decisions run on Jev or Laya. Use Dcision's key on TypeSafe, bring your own OpenRouter, TypeSafe or Vercel AI Gateway key, or run Laya on your own server — plus cost, limits and errors.
Source: https://docs.dcision.io/docs/concepts/engines-and-byok
## Engines [#engines]
Dcision runs decisions on two engines. Both are decision models — trained to return decisions and calibrated probabilities instead of generated text — and both speak the same *System One* contract: choice, score and probability questions answered with a probability distribution, which Dcision turns into typed answers, confidence and weighted levels.
| Engine | `metrics.engine` | What it is | Where it runs |
| ----------------- | ---------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| **Jev** (default) | `"jev"` | TypeSafe's *System One* decision model. | TypeSafe, OpenRouter or Vercel AI Gateway — on Dcision's key or yours. |
| **Laya** | `"laya"` | Open-source decision model (Apache 2.0) by Convai Innovations, 322M–421M parameters. | A Laya server: yours, or Dcision's when available. See [Laya](#laya). |
Responses report the engine in `metrics.engine` and the exact model that answered in `metrics.model`.
The decision schema doesn't depend on the engine: Dcision compiles it into an engine-neutral representation and only the engine adapter speaks to the model. The state, the decision's context and the questions — text or structured JSON — are sent in the engine's native shapes, and all the questions of a decision go in **one** request.
### Which engine runs a decision [#which-engine-runs-a-decision]
**Settings → Engine** sets the workspace's **default engine**. A decision can pick its own in **Engine & runtime → Engine** ([`settings.engine`](https://docs.dcision.io/docs/concepts/settings#engine)); without it, it follows the default. Each engine keeps its own credential and model, so a workspace can run most decisions on Jev and one on Laya.
## Jev: who runs the engine call [#jev-who-runs-the-engine-call]
Choose it in **Settings → Engine** (Admins and the Owner). The choice applies immediately to every Jev decision of the workspace — Playground and API.
| Option | What happens |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Dcision engine key** (default) | Dcision calls **TypeSafe** (Jev direct) with its own key, included in your plan. Pick the model: `jev-1.13.0` by default. Nothing else to configure. |
| **My own provider key** (BYOK) | Dcision calls the engine with your key on the provider and model you choose. The provider bills your account directly. |
### Models on Dcision's key [#models-on-dcisions-key]
| Model | What it is |
| ------------- | ------------------------------------------------------------------------------------------------------ |
| `jev-1.13.0` | **Pinned** (default, recommended). A fixed version: answers — and confidence — don't change under you. |
| `jev-latest` | The latest stable release. Moves when TypeSafe ships a new version. |
| `jev-preview` | Preview builds, ahead of `jev-latest` when one is available. |
Aliases move when TypeSafe ships a new release, so the answers behind them can change without a change on your side. `metrics.model` and the execution log always record the exact version that answered — `jev-1.13.0` even when you selected `jev-latest`.
Thresholds such as `minConfidence` are tuned against a model. Use the pinned `jev-1.13.0` for production decisions, and move to a new version on your own schedule after re-testing in the Playground.
### Providers [#providers]
All three expose the same System One contract:
| Provider | Endpoint | Models | Where to get a key |
| --------------------- | ---------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------- |
| OpenRouter | `https://openrouter.ai/api/v1/systemone` | `typesafe/jev-1.13` | [openrouter.ai/keys](https://openrouter.ai/keys) |
| TypeSafe (Jev direct) | `https://api.typesafe.ai/v1/systemone` | `jev-1.13.0` (default), `jev-latest`, `jev-preview` | [docs.typesafe.ai](https://docs.typesafe.ai) |
| Vercel AI Gateway | `https://ai-gateway.vercel.sh/typesafe/v1/systemone` | `typesafe-ai/jev` | [vercel.com/docs/ai-gateway](https://vercel.com/docs/ai-gateway) |
## Laya [#laya]
[Laya](https://laya.convaiinnovations.com) is an open-source *System One* model: no per-token price, and it runs where you run it — your data never leaves your server. Its `laya-serve` server exposes the same `/v1/systemone` contract as Jev, so Dcision calls it with the same questions.
| Model | What it is |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto` | **Default.** Laya's router picks the checkpoint from the language of the input. `metrics.model` reports the one that answered, e.g. `laya/multilingual`. |
| `english` | English checkpoint (ModernBERT-large, 421M). Reads up to 512 tokens. |
| `multilingual` | 100+ languages (mmBERT, 322M), the fastest. Reads up to 1,024 tokens. |
| `typed-decisions` | Fine-tuned for agent observability and customer-service workflows. Reads up to 1,024 tokens. |
### Run your Laya server [#run-your-laya-server]
Run `laya-serve` (Python 3.10+) on a host Dcision can reach over **public HTTPS**, behind your reverse proxy:
```bash
pip install "laya[serve]"
LAYA_API_KEY= LAYA_PRELOAD=1 laya-serve # listens on :8000
```
The project also ships a Dockerfile and compose files for CPU and CUDA. A GPU answers in \~35 ms; a CPU in a few hundred ms per decision.
In **Settings → Engine → Laya server**, enter the server's URL (`https://laya.example.com` — Dcision calls `/v1/systemone` on it) and the `LAYA_API_KEY` if you set one. Click **Save server**, then **Test**.
Select **Laya** as the default engine — or pick it in one decision's settings — choose the model and **Save engine settings**.
`laya-serve` has no authentication unless `LAYA_API_KEY` is set. Set it before exposing the server, and paste it in Settings → Engine. Dcision only calls public `https://` addresses — never private, loopback or cloud-metadata ones — and doesn't follow redirects.
**Shorter context than Jev.** Laya reads up to 512 tokens (`english`) or 1,024 (`multilingual`, `typed-decisions`) of state and question; longer inputs are truncated by the server. For long states, prefer `multilingual` or keep Jev. **Re-test thresholds** (`minConfidence`, policy ranges) in the Playground when moving a decision to Laya: each model has its own confidence profile.
## Bring your own key [#bring-your-own-key]
In **Settings → Engine → Provider keys**, paste the key next to the provider and click **Save key**.
Click **Test**. Dcision runs a tiny probe decision (the Spam Detection template on a one-line message) with your key and shows the model and latency, or the provider's error. The probe isn't recorded as an execution, but your provider may bill it.
Select **My own provider key**, the provider and the model, then **Save engine settings**. New runs use them immediately.
If you select *My own provider key* without saving a key for that provider, every run fails with `424 ENGINE_NOT_CONFIGURED`.
### How keys are protected [#how-keys-are-protected]
* Encrypted at rest with **AES-256-GCM**; the API never returns them — only the last 4 characters are shown.
* Never written to logs and never sent anywhere but the provider's endpoint.
* Only Admins and the Owner can add, replace, test or remove them; everyone else in the workspace sees which providers have a key, with its last 4 characters.
* Removing a key deletes it. Save a new key to rotate.
### Keys for LLM destinations [#keys-for-llm-destinations]
The OpenRouter and Vercel AI Gateway keys saved here also power [LLM destinations](https://docs.dcision.io/docs/destinations/llm) — the answers a decision can generate for a route. They are used for those answers even when the engine runs on Dcision's key, and the provider bills their tokens to your account. Dcision's engine key never runs LLM destinations.
## Limits of the engine [#limits-of-the-engine]
* **Token budget.** Jev reads the state once and evaluates every question against it: about 32,000 tokens for the state plus the longest question and 64,000 for the state plus all questions. Dcision checks both before the call and answers `422 INVALID_STATE` when a state doesn't fit — see [Limits](https://docs.dcision.io/docs/api/limits).
* **Throughput.** TypeSafe limits each account in requests and tokens per second. On Dcision's key, the limit is shared by all workspaces, so Dcision smooths bursts: a call waits for a free slot within its deadline instead of failing at once. With your own key, your provider account's limits apply.
* **Text only.** The state must be text or JSON; convert images, audio or files to text first.
## Cost [#cost]
On **Laya** the engine cost is the server you run: Dcision estimates `estimated_cost_usd: 0` for Laya calls. A successful API run still counts as a [billable decision](https://docs.dcision.io/docs/plans-and-billing#what-is-billed).
On **Jev**, each response includes `metrics.input_tokens`, `metrics.output_tokens` and `metrics.estimated_cost_usd`: the input tokens of the call times the model's price. Jev is priced at **US$0.042 per 1M input tokens** and doesn't bill output tokens — they are reported for observability only — so a decision of 375 input tokens is estimated at `0.00001575`. This is an estimate of the **engine** cost — useful to compare decisions — not your Dcision invoice.
All the questions of a decision share one call, so adding a question adds its own tokens to the call, not another request.
With your own key, the provider bills the engine call. The call still counts as a [billable decision](https://docs.dcision.io/docs/plans-and-billing#what-is-billed) on your Dcision plan when it succeeds through the API.
## Engine errors [#engine-errors]
| Code | Status | Cause |
| ------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `ENGINE_NOT_CONFIGURED` | 424 | No key saved for the selected provider, no Laya server for a Laya decision, or Dcision's engine key is unavailable. |
| `ENGINE_AUTH_FAILED` | 502 | The provider rejected the key (401/403). Check it in Settings → Engine. |
| `ENGINE_RATE_LIMITED` | 503 | The provider is overloaded or rate limited (429/503/529), or Dcision's shared key had no free slot before the deadline. |
| `ENGINE_UNAVAILABLE` | 503 | The provider failed (5xx) or couldn't be reached. |
| `ENGINE_TIMEOUT` | 504 | No answer within the decision's `timeoutMs`. |
| `ENGINE_INVALID_REQUEST` | 502 | The provider rejected the request; the message includes its reason. Also a Laya server URL that resolves to a private or reserved address. |
| `ENGINE_ERROR` | 502 | The engine answered outside the contract (for example an unknown option). |
Within the decision's `timeoutMs`, Dcision makes **up to 2 retries** on network errors, `429`/`503`/`529` and other `5xx` answers, honoring the provider's `Retry-After` — see [timeoutMs](https://docs.dcision.io/docs/concepts/settings#timeoutms). Failed runs are recorded as executions with status *Error* and are not billed. With [`onEngineError: "fallback"`](https://docs.dcision.io/docs/concepts/settings#onengineerror), every error of this table except `ENGINE_NOT_CONFIGURED` becomes a `200` with the decision's fallback action. See [Errors](https://docs.dcision.io/docs/api/errors).
---
# Playground
> Test drafts — including unsaved edits — with real payloads, read confidence and distributions, preview destinations, compare runs and replay inputs from past executions.
Source: https://docs.dcision.io/docs/concepts/playground
The Playground runs a decision's **draft** on a state you provide and shows everything the API would return, plus the full distributions. Use it to iterate on instructions and option descriptions before you deploy.
Open it from a decision's **Playground** tab, or from **Playground** in the sidebar and pick a decision there. On that page, a decision whose slug is a template id — such as `lead-qualification` — starts with the template's sample state.
## What it runs [#what-it-runs]
The Playground runs the **draft**, never the deployed version. From the decision's **Playground** tab it even runs edits you haven't saved yet, and it is blocked while the draft has schema issues; the sidebar's **Playground** page runs the last saved draft. When the decision is live, the header reminds you that production keeps the active version.
## Run a state [#run-a-state]
1. Write the state as JSON. For an object state, an object; for a [text state](https://docs.dcision.io/docs/concepts/decision-schema#text-state), a JSON string such as `"Hi, I'd like pricing for 50 seats."`; for a [list state](https://docs.dcision.io/docs/concepts/decision-schema#list-state), an array such as `["Hi!", "We're a team of 50 and want pricing."]`.
2. Use **Sample** to generate a state from the state schema and **Format** to pretty-print it. The editor shows the size in bytes and whether the JSON is valid.
3. Click **Run decision** or press ⌘/Ctrl + Enter.
## Read the result [#read-the-result]
* The typed `result`, the **action** badge and the reason in plain words — *Policy rule #1 matched*, *"route" answered "other" (no option fits)*, *"route" confidence 52% \< 80%*, *Engine unavailable (ENGINE_TIMEOUT); fallback applied* or *No rule matched*.
* A **confidence bar per question**. Expand it to see the distribution over every option or level — and, for a score question, its **weighted level** (1 = first level), the number rules with *weighted level* compare. Questions with a [near tie](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#near-ties) or below their minimum confidence open automatically.
* A **Composites** card with the value of each [composite](https://docs.dcision.io/docs/concepts/composites).
* **Latency**, **model**, **estimated cost** with the input tokens, and a link to the **execution**.
When the engine fails on a decision with [`onEngineError: "fallback"`](https://docs.dcision.io/docs/concepts/settings#onengineerror), a warning says the decision answered with its fallback action and that the call isn't billed.
## Destinations [#destinations]
For decisions with [destinations](https://docs.dcision.io/docs/destinations), the result ends with a **Destinations** card. By default it is a **preview** and nothing leaves Dcision: fixed replies are rendered, LLM destinations show the prompt they would send, and webhooks, API requests, workflows and agents show the exact request — with secrets masked as `••••`.
Switch on **Run destinations for real**, next to **Run decision**, to call the LLMs and agents and send the deliveries, marked `livemode: false`. The card then follows each delivery — sending, retrying, delivered or failed — and offers **Resend** on failures. See [Testing destinations](https://docs.dcision.io/docs/destinations/testing).
## Compare runs [#compare-runs]
From the second run on, **Compared with the previous run** lists every answer and the action before and after, highlighting what changed — the fastest way to see the effect of a reworded instruction or option description.
## Replay past inputs [#replay-past-inputs]
In **Executions**, open a run and click **Re-run this input in the Playground**: the stored state loads into that decision's Playground, ready to run against the current draft. It's a quick regression test after an edit.
Replay needs the stored input, so it isn't available for executions of decisions with [`storeInput`](https://docs.dcision.io/docs/concepts/settings#storeinput-and-storeoutput) off.
## Code snippets [#code-snippets]
Below the result, the Playground prints the same call as **cURL**, **TypeScript** and **Python** for the current state. They target the deployed endpoint, so they work once the decision is deployed and you have an [API key](https://docs.dcision.io/docs/api/authentication).
## Limits and cost [#limits-and-cost]
* Playground runs are **not billed** on any plan and don't use your monthly volume.
* They run on your workspace's [engine settings](https://docs.dcision.io/docs/concepts/engines-and-byok): with your own provider key, the provider bills them.
* Up to **30 runs per minute** and **2,000 runs per day** per workspace; above that you get `429 RATE_LIMITED` — use an API key for volume.
* Every run is recorded in **Executions** with source *Playground* and version *draft*.
* Running the Playground needs the Member role or higher: Viewers can open it but not run it — see [Team, roles and account](https://docs.dcision.io/docs/team-and-roles).
---
# Executions and usage
> Every run is logged with its version, action reason, confidence and — if you allow it — input and output. Find executions, read usage and know how long logs are kept.
Source: https://docs.dcision.io/docs/concepts/executions-and-usage
## Executions [#executions]
Every run — from the API or the Playground, successful or not — is recorded as an **execution**. Its ID is the `execution_id` of the response.
| Recorded | Notes |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Decision and version | `v3`, or `draft` for Playground runs |
| Source | *API* or *Playground* |
| Status and error | *Success* or *Error*, with the error code and message |
| Action and action reason | for successful runs |
| Latency, model, input and output tokens, estimated cost | the model is the exact version that answered |
| Request ID | the `X-Request-ID` of the call — yours or the one Dcision generated |
| Input (`state`) | only when the decision's [`storeInput`](https://docs.dcision.io/docs/concepts/settings#storeinput-and-storeoutput) is on |
| Output, confidence, weighted levels, composites, distributions | only when `storeOutput` is on |
| Destinations | the [destination entries](https://docs.dcision.io/docs/api/run-decision#destinations) of the run — replies, LLM and agent answers, functions with their params, queued deliveries — whatever `storeInput` and `storeOutput` say |
Failed calls are recorded too — including invalid states (`INVALID_STATE`, token budget included) and engine errors — so you can see what your integration sends. A call answered with the [engine-error fallback](https://docs.dcision.io/docs/concepts/settings#onengineerror) is recorded as an *Error* with the engine's code, although the API returned `200`. Requests rejected before the decision runs — an invalid key or body, an unknown, disabled or undeployed decision, a rate limit, a quota block, an idempotent replay — are not recorded.
### Find an execution [#find-an-execution]
**Executions** lists the most recent runs first, 50 at a time (**Load older** for more). Filter by status, source and decision, or search by the beginning of an execution ID or by a decision name or slug. Selecting a run opens its details: action and reason, error, latency, model, estimated cost, request ID, input, output and the distribution of every question, its destinations with the live status of their deliveries — plus **Re-run this input in the Playground**.
Store `execution_id` with the record your application acted on, and send your own `X-Request-ID` to correlate Dcision runs with your logs.
### Read executions from code [#read-executions-from-code]
[`GET /v1/executions`](https://docs.dcision.io/docs/api/executions) lists the same runs with an API key — answers, action, reason, status, error code and metrics, filtered by decision and status, newest first. It never returns the input: stored states stay in the app, for workspace members.
## Usage [#usage]
**Usage** summarizes today, the last 7 days or the last 30 days:
* API calls and Playground runs;
* successful decisions and the error rate;
* average latency of all runs, and p50 and p95 latency of successful runs;
* estimated engine cost and input tokens;
* a volume chart — per hour for today, per day otherwise — with errors as a dashed line;
* a breakdown by decision: calls, errors, average latency, estimated cost and share.
The top card shows the **billable decisions** of the current billing period against your plan's included volume — also available from code with [`GET /v1/me`](https://docs.dcision.io/docs/api/me). The **Dashboard** repeats the key numbers (decisions executed, p95 latency, success rate, estimated engine cost) with your live decisions, recent executions and the getting-started checklist.
### Per decision: the Overview [#per-decision-the-overview]
Every decision's first tab, **Overview**, sums up its runs for 7, 30 or 90 days — success rate, latency, cost, actions and their reasons, per-question statistics, destinations — and suggests what to calibrate. See [Overview and calibration](https://docs.dcision.io/docs/guides/overview-and-calibration).
## Retention [#retention]
A daily job deletes executions older than the **shorter** of your plan's retention and the workspace setting (**Settings → Execution log retention**):
| Plan | Log retention |
| ---------- | ------------- |
| Genesis | 7 days |
| Developer | 14 days |
| Growth | 30 days |
| Enterprise | 365 days |
For example, a Growth workspace set to 7 days keeps 7 days; a Genesis workspace set to 90 days keeps 7.
* Usage charts and tables are computed from executions, so they only cover runs that are still retained.
* **Billing doesn't depend on logs.** Billable decisions are counted separately, per hour, and those counters are kept for about 400 days: retention never gives quota back or changes what you pay.
* Deleting a decision deletes its executions immediately.
* Destination deliveries follow their own retention: they are deleted 30 days after they were created — see [Delivery and retries](https://docs.dcision.io/docs/destinations/delivery#retention).
## Quota alerts [#quota-alerts]
Workspace owners get an e-mail when the billable decisions of the period reach **80%** and **100%** of the included volume — once per level per period. See [Plans, quotas and billing](https://docs.dcision.io/docs/plans-and-billing).
---
# Patterns overview
> TypeSafe's four architectural patterns — speculative fan-out, confidence-gated routing, composite scoring and intent routing — and how each maps to Dcision features and templates.
Source: https://docs.dcision.io/docs/patterns
Jev answers small, literal questions quickly. Good systems compose many of them. TypeSafe documents four [architectural patterns](https://docs.typesafe.ai/patterns) for doing it well, and Dcision turns each one into first-class features of the decision schema — with a template that implements it end to end.
| Pattern | What it does | Benefits | In Dcision | Template |
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------- | ------------------------- |
| [Speculative fan-out](https://docs.dcision.io/docs/patterns/fan-out) | Ask every question the flow might need in one call and let rules decide which answers matter. | Cost, speed | All questions in one decision; `and` conditions gate the speculative answers. | `ticket-triage` |
| [Confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing) | Use confidence as a second axis: the answer says what, confidence says whether to act. | Reliability, safety | `minConfidence` per question and rules `on: "confidence"`, one threshold per action. | `voice-banking` |
| [Composite scoring](https://docs.dcision.io/docs/patterns/composite-scoring) | Break a judgment into atomic scores and combine them with weights you control. | Cost, reliability, speed | One question per dimension and [composites](https://docs.dcision.io/docs/concepts/composites), usable in rules. | `resume-screening` |
| [Intent routing](https://docs.dcision.io/docs/patterns/intent-routing) | Classify each request in one fast call and send it to the cheapest capable handler. | Cost, speed | One choice for the intent (with the reserved `other`), rules for what a person must see. | `customer-service-router` |
## Why patterns [#why-patterns]
Every decision is **one engine call**: Jev reads the state once and answers all the questions in parallel, so adding questions barely changes latency, and Dcision bills one decision per call however many questions it has. The patterns take advantage of that, and of the fact that every answer comes with a calibrated probability:
* **Ask more, not more often** — [fan-out](https://docs.dcision.io/docs/patterns/fan-out) answers a whole decision tree in one round trip.
* **Act only when sure enough** — [confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing) scales the required confidence with the stakes of each action.
* **Keep the math outside the model** — [composite scoring](https://docs.dcision.io/docs/patterns/composite-scoring) combines atomic answers with weights stored in the decision.
* **Spend expensive resources last** — [intent routing](https://docs.dcision.io/docs/patterns/intent-routing) sends lookups to code, hard cases to specialist LLMs and the rest to people.
## The building blocks [#the-building-blocks]
| Feature | Useful for |
| ------------------------------------------------------------------ | ------------------------------------------------- |
| Up to 64 questions per decision, answered in one call | fan-out, composite scoring, intent routing |
| Policy rules with up to 5 `and` conditions, first match wins | fan-out, confidence-gated routing, intent routing |
| `minConfidence` per question and rules on `confidence` | confidence-gated routing |
| Weighted levels of score questions (`scores`, rules `on: "score"`) | fan-out, intent routing |
| Composites, returned in `composites` and usable in rules | composite scoring |
| The reserved `other` option and the fallback action | intent routing, confidence-gated routing |
| `onEngineError: "fallback"` — a safe action when the engine fails | every pattern on a path that must always answer |
See [Decision schema](https://docs.dcision.io/docs/concepts/decision-schema), [Policies and actions](https://docs.dcision.io/docs/concepts/policies-and-actions) and [Decision settings](https://docs.dcision.io/docs/concepts/settings).
## Patterns in the app [#patterns-in-the-app]
* **Templates** opens with a **Patterns** section: one card per pattern with what it is, when to use it, how to build it in Dcision, its benefits, links to the templates that implement it and to TypeSafe's guide. Below it, **All templates** shows each template's patterns as badges.
* **New decision** lists the patterns of each template next to its number of questions.
* The editor's **Patterns** panel, under the decision schema in the **Editor** tab, says how many of the four patterns the decision uses. Each entry expands to explain the pattern and to show either how this decision uses it (*Here*) or how to adopt it (*Try*).
The panel detects a pattern from the schema:
| Pattern | Detected when the decision has… |
| ------------------------ | --------------------------------------------------------------------------------------- |
| Speculative fan-out | 4 or more questions, or 3 or more with a rule whose conditions use different fields |
| Confidence-gated routing | a question with a minimum confidence, or a rule on `confidence` |
| Composite scoring | at least one composite |
| Intent routing | a choice question with at least 3 options besides `other`, or a rule on a choice answer |
## Patterns combine [#patterns-combine]
Most production decisions use more than one pattern. The [Lead Qualification](https://docs.dcision.io/docs/guides/lead-qualification) template routes the lead to a team (intent routing), escalates when `purchase_intent` isn't confident enough (confidence-gated routing) and ranks leads with `lead_score` (composite scoring) — in one call. [Templates](https://docs.dcision.io/docs/concepts/templates) lists the patterns of every template.
Patterns compose answers; each answer still has to be good. Keep questions atomic and literal, keep math and dates in code and send only the state the questions need — see [Writing good questions](https://docs.dcision.io/docs/guides/writing-good-questions).
---
# Speculative fan-out
> Ask every question a flow might need in one call — Jev answers them in parallel — and let AND rules and your code decide which answers matter. Built on the Support Ticket Triage template.
Source: https://docs.dcision.io/docs/patterns/fan-out
Ask every question the flow might need in **one call** — Jev answers them in parallel — and let rules and your code decide which answers matter. Instead of asking for a ticket's category first and its bug severity in a follow-up call, ask both at once: if the ticket isn't a bug report, you simply ignore the severity.
Benefits: **cost** and **speed**. TypeSafe's guide: [Speculative fan-out](https://docs.typesafe.ai/patterns/fan-out).
## Why it works [#why-it-works]
* **One round trip.** Jev reads the state once and evaluates every question against it independently and in parallel, so extra questions add little latency. In TypeSafe's [parallel questions](https://docs.typesafe.ai/cookbooks/parallel_questions) cookbook, batching 13 questions into one call was about 12× cheaper and 10× faster than one call per question, with the same answers.
* **One billable decision.** Dcision bills per successful call, not per question: a decision with five questions is one decision on your plan. The engine cost grows only with the input tokens of the extra questions — see `metrics.input_tokens`.
* **No hidden coupling.** Each answer is computed on its own; one question's answer never becomes context for another. Your rules combine them afterwards.
## When to use it [#when-to-use-it]
Triage where later steps depend on earlier answers — category → bug severity → refund — and any flow that would otherwise chain calls ("if A, then ask B"). A decision holds up to **64 questions**, within Jev's token budget: about 32,000 tokens for the state plus the longest question, 64,000 for the state plus all questions.
## How it maps to Dcision [#how-it-maps-to-dcision]
| In TypeSafe's guide | In Dcision |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| All questions in one request | All questions in one decision — one `POST /v1/decisions/{slug}` |
| Speculative questions | Questions that only matter in some branches, such as `bug_severity` |
| `if category == "bug_report" and severity > …` in your code | A rule with `and` conditions: *category = bug_report **AND** bug_severity ≥ 4 → escalate* |
| `frustration.score > 1.5` (levels counted from 0) | A rule on the weighted level: *frustration (weighted level) ≥ 3.5 → escalate* (levels counted from 1) |
Rules hold the routing that must be consistent and auditable; your code still reads every answer in `result`.
## Build it [#build-it]
### In the app [#in-the-app]
Open **Templates**, find **Support Ticket Triage (fan-out)** — under *All templates*, or **Try** on the *Speculative fan-out* card — and choose it. In **Name it**, call the decision **Ticket Triage**: the slug, your endpoint, comes from the name and becomes `ticket-triage`. (The template's full name would give `support-ticket-triage-fan-out`; you can also edit the slug in the editor until the first deploy.) Click **Create decision**.
The **State** is a JSON object with `subject` (string) and `body` (string, required).
The **Questions** are the whole decision tree at once:
| Question | Type | Answers | Matters when |
| ------------------------ | ----------- | ------------------------------------------------------------- | ------------------------- |
| `category` | choice | `bug_report`, `billing`, `feature_request`, `how_to`, `other` | always |
| `bug_severity` | score | `cosmetic`, `minor`, `major`, `critical` | bug reports (speculative) |
| `has_reproducible_steps` | probability | the ticket includes steps to reproduce the problem | bug reports (speculative) |
| `refund_requested` | probability | the customer explicitly asks for a refund or credit | billing (speculative) |
| `frustration` | score | `calm`, `annoyed`, `frustrated`, `very angry` | always |
The `critical` level is a structured level — `{ "label": "critical", "rubric": "outage, data loss, security issue or money at risk" }` — so the engine reads the rubric while the API returns the label `critical`.
In **Policies**, each rule reads *IF condition \[AND condition…] THEN action*:
1. **IF** `category` · *answer* · = · `bug_report`, then **+ AND** `bug_severity` · *answer* · ≥ · *4 · critical* — **THEN** `escalate`.
2. **IF** `refund_requested` · *answer* · ≥ · 0.8 — **THEN** `escalate`.
3. **IF** `frustration` · *weighted level* · ≥ · 3.5 — **THEN** `escalate`.
The fallback action stays `escalate`, so a ticket that fits no category (`other`) also reaches a person.
Run the sample ticket in the **Playground**, check the answers and the action, then **Deploy v1**. The editor's **Patterns** panel now marks *Speculative fan-out* and *Intent routing* as in use.
### The decision schema [#the-decision-schema]
The questions and rules, as the editor's **Decision schema** view shows them (the reserved `other` option is added to `category` automatically):
```json
{
"questions": [
{
"key": "category",
"type": "choice",
"instructions": "What kind of support ticket is this?",
"options": [
{ "value": "bug_report", "description": "something in the product is broken or behaves incorrectly" },
{ "value": "billing", "description": "charges, invoices, refunds, plans, payment methods" },
{ "value": "feature_request", "description": "asks for something the product doesn't do yet" },
{ "value": "how_to", "description": "asks how to do something that already works" }
]
},
{
"key": "bug_severity",
"type": "score",
"instructions": "If this describes a bug, how severe is it for the customer?",
"scale": ["cosmetic", "minor", "major", { "label": "critical", "rubric": "outage, data loss, security issue or money at risk" }]
},
{ "key": "has_reproducible_steps", "type": "probability", "instructions": "Does the ticket include steps to reproduce the problem?" },
{ "key": "refund_requested", "type": "probability", "instructions": "Does the customer explicitly ask for a refund or credit?" },
{ "key": "frustration", "type": "score", "instructions": "How frustrated is the customer?", "scale": ["calm", "annoyed", "frustrated", "very angry"] }
],
"policies": [
{
"field": "category",
"on": "output",
"operator": "eq",
"value": "bug_report",
"and": [{ "field": "bug_severity", "on": "output", "operator": "gte", "value": 4 }],
"action": "escalate"
},
{ "field": "refund_requested", "on": "output", "operator": "gte", "value": 0.8, "action": "escalate" },
{ "field": "frustration", "on": "score", "operator": "gte", "value": 3.5, "action": "escalate" }
],
"settings": { "fallbackAction": "escalate" }
}
```
With the [CLI](https://docs.dcision.io/docs/cli), `dcision init triage.json --template ticket-triage` writes the complete schema to a file.
## Call it [#call-it]
```bash
curl -X POST https://api.dcision.io/v1/decisions/ticket-triage \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ticket-90412" \
-d '{
"state": {
"subject": "Payouts failing",
"body": "Our payouts have been failing for 3 days and customers are complaining. Steps: open Payouts, click Retry, error 500. Please fix ASAP."
}
}'
```
```json title="200 OK"
{
"decision_id": "dec_Tq4Wm8Lz2Xv6Rk1Np9Cs",
"execution_id": "exec_Jd7Hs2Qa5Wn8Lx3Kv1Mz",
"schema": "ticket-triage",
"version": 1,
"result": {
"category": "bug_report",
"bug_severity": "critical",
"has_reproducible_steps": 0.91,
"refund_requested": 0.04,
"frustration": "frustrated"
},
"confidence": {
"category": 0.9125,
"bug_severity": 0.56,
"has_reproducible_steps": 0.82,
"refund_requested": 0.92,
"frustration": 0.59
},
"scores": { "bug_severity": 3.56, "frustration": 2.97 },
"action": "escalate",
"action_reason": { "type": "policy", "rule": 0 },
"metrics": {
"latency_ms": 368,
"engine": "jev",
"model": "jev-1.13.0",
"estimated_cost_usd": 0.00002184,
"input_tokens": 520,
"output_tokens": 58
}
}
```
Five answers from one call. Rule 0 matched: the ticket is a `bug_report` **and** its most likely severity is `critical` (level 4). `scores` holds the weighted level of each score question — `bug_severity` sits at 3.56, between `major` and `critical`.
## Act on it [#act-on-it]
The rules decided whether a person must look now; your code reads the speculative answers only in the branch where they matter:
```js
const decision = await response.json();
const { result } = decision;
if (decision.action === "escalate") {
return onCall.page(ticket, { reason: decision.action_reason, executionId: decision.execution_id });
}
switch (result.category) {
case "bug_report":
return result.has_reproducible_steps >= 0.6
? engineering.triage(ticket, { severity: result.bug_severity })
: bugBacklog.add(ticket, { severity: result.bug_severity });
case "billing":
return billing.assign(ticket, { refundLikely: result.refund_requested >= 0.7 });
case "feature_request":
return roadmap.log(ticket);
case "how_to":
return autoReply.withDocs(ticket);
}
```
`frustration` is useful in every branch — for example, raise the priority when `decision.scores.frustration` is 3 or more, even below the escalation threshold.
## Tune it [#tune-it]
* **Add speculative questions freely.** Each one costs its input tokens, not a round trip. Stay within 64 questions and the token budget.
* **Keep each question atomic.** One judgment per question, read literally — see [Writing good questions](https://docs.dcision.io/docs/guides/writing-good-questions).
* **Gate with `and`, not with longer instructions.** "If this describes a bug, how severe…" stays a simple question; the rule decides when its answer counts.
* **Threshold between levels** with `on: "score"`: `frustration ≥ 3.5` triggers when the weight sits between `frustration` and `very angry`, not only when `very angry` wins.
* **Order rules from specific to general**: the first match wins.
* **Send only the ticket.** Long threads and unrelated fields dilute accuracy and use the token budget — filter them in your code.
Related: [Policies and actions](https://docs.dcision.io/docs/concepts/policies-and-actions) · [Questions](https://docs.dcision.io/docs/concepts/questions) · [Support routing](https://docs.dcision.io/docs/guides/support-routing)
---
# Confidence-gated routing
> Use confidence as a second axis — the answer says what, confidence says whether to act — with a minimum confidence per question and per-action AND rules. Built on the Voice Banking template.
Source: https://docs.dcision.io/docs/patterns/confidence-routing
Use confidence as a **second axis**: the answer tells you *what*, confidence tells you *whether to act on it*. Some actions are riskier than others, so they need more certainty before your system acts on its own.
Benefits: **reliability** and **safety**. TypeSafe's guide: [Confidence-gated routing](https://docs.typesafe.ai/patterns/confidence-routing).
## Why it works [#why-it-works]
Jev is trained to return **calibrated** probabilities: across many answers, those given about 80% should be right about 80% of the time. Every answer carries a [confidence](https://docs.dcision.io/docs/concepts/confidence-and-probabilities) between 0 and 1 derived from that distribution — 1 when all the probability sits on one answer, 0 when it is spread evenly. "I'm not sure" becomes a number your system can route on, instead of a confident wrong answer.
A useful starting point is three paths:
| Confidence | Behavior |
| ---------- | ------------------------------------------------------------------------------------------ |
| High | Act automatically. |
| Medium | Proceed with caution: ask the user to confirm, flag for review or gather more information. |
| Low | Don't act: route to a person, ask for clarification or fall back to another system. |
Where the boundaries sit depends on the stakes: **thresholds scale with risk**. Reading a balance at 60% confidence is fine — the worst case is a wrong screen. Approving a transfer is not.
## When to use it [#when-to-use-it]
Any action with consequences: payments, account changes, deletions, messages sent on someone's behalf, moderation that removes content. Pair it with [intent routing](https://docs.dcision.io/docs/patterns/intent-routing): the intent picks the handler, confidence decides whether the handler may act alone.
## How it maps to Dcision [#how-it-maps-to-dcision]
| In TypeSafe's guide | In Dcision |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `if action.confidence < 0.6: route_to_support_agent()` | `minConfidence: 0.6` on the question — below it, the decision returns its fallback action (`escalate`) with `action_reason.type = "low_confidence"` |
| `elif choice == "approve_transfer" and confidence > 0.85: approve()` | A rule per risky action: *intent = approve_transfer **AND** intent confidence below 0.9 → escalate* |
| `else: ask_user_to_confirm()` | Your code maps `escalate` from that rule to "ask the user to confirm" |
| A catch-all option | The reserved `other` option, which also returns the fallback action |
The rules run first, then the `other` check, then `minConfidence` — see [How the action is computed](https://docs.dcision.io/docs/concepts/policies-and-actions#how-the-action-is-computed).
## Build it [#build-it]
### In the app [#in-the-app]
Open **Templates** and choose **Voice Banking Commands (confidence-gated)**. Name the decision **Voice Banking** so its slug is `voice-banking`, then **Create decision**.
The **State** is **Text**: your speech-to-text transcript of the command.
The only question, `intent`, is a choice: `check_balance` (read-only), `transfer_money`, `approve_transfer`, `block_card` and the reserved `other`. Its **Minimum confidence** is on, at **60%** — the floor for every action.
In **Policies**, one rule per risky action, using the question's confidence as a second condition:
1. **IF** `intent` · *answer* · = · `approve_transfer`, **+ AND** `intent` · *confidence* · \< · 0.9 — **THEN** `escalate`.
2. **IF** `intent` · *answer* · = · `transfer_money`, **+ AND** `intent` · *confidence* · \< · 0.8 — **THEN** `escalate`.
In **Engine & runtime**, keep the **Fallback action** at `escalate`. For a voice channel that must always answer, consider **If the engine fails → Answer with the fallback action** (see [Decision settings](https://docs.dcision.io/docs/concepts/settings#onengineerror)). Test, then **Deploy v1**.
### The decision schema [#the-decision-schema]
```json
{
"stateSchema": { "kind": "text" },
"questions": [
{
"key": "intent",
"type": "choice",
"instructions": "What does the customer want to do with their account?",
"minConfidence": 0.6,
"options": [
{ "value": "check_balance", "description": "hear the balance or recent transactions (read-only)" },
{ "value": "transfer_money", "description": "start a new transfer to someone" },
{ "value": "approve_transfer", "description": "confirm a transfer that is waiting for approval" },
{ "value": "block_card", "description": "block or freeze a card" }
]
}
],
"policies": [
{
"field": "intent",
"on": "output",
"operator": "eq",
"value": "approve_transfer",
"and": [{ "field": "intent", "on": "confidence", "operator": "lt", "value": 0.9 }],
"action": "escalate"
},
{
"field": "intent",
"on": "output",
"operator": "eq",
"value": "transfer_money",
"and": [{ "field": "intent", "on": "confidence", "operator": "lt", "value": 0.8 }],
"action": "escalate"
}
],
"settings": { "fallbackAction": "escalate" }
}
```
## Call it [#call-it]
The state is the transcript, as a string:
```bash
curl -X POST https://api.dcision.io/v1/decisions/voice-banking \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "state": "Yeah, go ahead and approve that payment to my landlord." }'
```
```json title="200 OK"
{
"decision_id": "dec_Wb3Kq9Tz1Lm7Xv5Rn2Hd",
"execution_id": "exec_Pz8Nc4Wq1Ht6Ks3Lm9Vb",
"schema": "voice-banking",
"version": 1,
"result": { "intent": "approve_transfer" },
"confidence": { "intent": 0.8125 },
"action": "escalate",
"action_reason": { "type": "policy", "rule": 0 },
"metrics": {
"latency_ms": 248,
"engine": "jev",
"model": "jev-1.13.0",
"estimated_cost_usd": 0.00001008,
"input_tokens": 240,
"output_tokens": 14
}
}
```
The intent is clear enough to pass the 60% floor, but approving a transfer needs 90%: rule 0 escalates. The same decision on other commands:
| Command | `result.intent` | `confidence.intent` | `action` | `action_reason` |
| --------------------------------------------------------- | ------------------ | ------------------- | ---------- | --------------------------------------------------------------------------------------------- |
| "Yeah, go ahead and approve that payment to my landlord." | `approve_transfer` | 0.8125 | `escalate` | `{ "type": "policy", "rule": 0 }` |
| "What's my balance?" | `check_balance` | 0.95 | `continue` | `{ "type": "default" }` |
| "Uh, the thing from before, with the money" | `check_balance` | 0.4 | `escalate` | `{ "type": "low_confidence", "question": "intent", "confidence": 0.4, "minConfidence": 0.6 }` |
| "Change my mailing address" | `other` | 0.9 | `escalate` | `{ "type": "other_option", "question": "intent" }` |
## Act on it [#act-on-it]
`action_reason` tells the two kinds of `escalate` apart: a rule fired because a risky action wasn't certain enough — confirm it — or the decision didn't understand — hand over:
```js
const decision = await response.json();
const { action, action_reason: reason, result } = decision;
if (action === "continue") return perform(result.intent, account);
if (reason.type === "policy") {
// A risky intent below its threshold: verify instead of acting.
return voice.ask(`Just to confirm: you want to ${describe(result.intent)}, is that right?`);
}
return transferToAgent(call, { reason }); // low_confidence, other_option or engine_error
```
## Tune it [#tune-it]
* **Start conservative, then measure.** Pull recent runs with [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions) or the **Executions** screen, compare `confidence` with what really happened, and move each threshold on its own.
* **One rule per risk level.** A decision has up to 50 rules with up to 5 `and` conditions each — enough for a threshold per action, per customer tier or per amount band computed in your code.
* **Re-check thresholds when options change.** Choice confidence measures how far the top option sits above an even split between all options, `other` included: adding or removing options moves the scale.
* **Probability questions have a different confidence.** For a yes/no probability, confidence is `|2p − 1|`: a `minConfidence` of 0.6 flags every `p` between 0.2 and 0.8 — see [Confidence and probabilities](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#minimum-confidence-on-probability-questions).
* **Keep errors on the same path.** With `onEngineError: "fallback"`, an engine outage answers `escalate` with `action_reason.type = "engine_error"` instead of an HTTP error — handled above by the last line.
Related: [Handling low confidence and other](https://docs.dcision.io/docs/guides/low-confidence-and-other) · [Intent routing](https://docs.dcision.io/docs/patterns/intent-routing)
---
# Composite scoring
> Break a judgment into atomic scores and combine them with weights you control — composites computed by Dcision and usable in policies. Built on the Resume Screening template.
Source: https://docs.dcision.io/docs/patterns/composite-scoring
Break a complex judgment into **independent dimensions**, score each one separately, and combine them with **weights you control** — in the decision, not in a prompt. One broad question ("is this a good candidate?") hides several judgments behind one answer; atomic scores expose them so you can inspect, tune and combine them.
Benefits: **cost**, **reliability** and **speed**. TypeSafe's guide: [Composite scoring](https://docs.typesafe.ai/patterns/composite-scoring).
## Why it works [#why-it-works]
* **Each dimension is a small, literal question** — the kind Jev answers best — and all of them come back from one call.
* **The math stays out of the model.** Dcision computes the weighted combination exactly; Jev is good at judgment and weak at arithmetic.
* **You can see how a score was built.** Every term is in the response, so when the ranking surprises you, you know which dimension — or which weight — to change.
* **Several rankings from the same answers.** Two composites over the same four questions cost nothing extra: no new question, no new engine call.
## When to use it [#when-to-use-it]
Ranking or qualifying with several criteria: lead scores, resume fit, vendor risk, content quality, the priority of a backlog. Whenever you would write "consider A, B and C" in one instruction, ask A, B and C separately and combine them.
## How it maps to Dcision [#how-it-maps-to-dcision]
| In TypeSafe's guide | In Dcision |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| One score question per dimension | One score (or probability, or choice) question per dimension |
| `py = answers["python_depth"].score / 4` | Automatic: a score term is normalized to 0–1 as `(weighted level − 1) / (levels − 1)` |
| `ic_score = 0.40 * py + 0.10 * lead + …` in your code | A [composite](https://docs.dcision.io/docs/concepts/composites) with those weights: `Σ(weight × value) / Σ\|weight\|` |
| Rank and filter in code | Read `composites` in the response, and use composites in rules to set the `action` |
## Build it [#build-it]
### In the app [#in-the-app]
Open **Templates** and choose **Resume Screening (composite scoring)**. Name the decision **Resume Screening** so its slug is `resume-screening`, then **Create decision**.
The **State** has one required string field, `resume`.
Four **score** questions, five levels each, one per dimension:
| Question | Levels, lowest to highest |
| ----------------- | ----------------------------------------------------------------- |
| `python_depth` | `none`, `basic`, `working`, `strong`, `expert` |
| `team_leadership` | `none`, `informal`, `tech lead`, `manager`, `manager of managers` |
| `system_design` | `none`, `basic`, `working`, `strong`, `expert` |
| `generalist` | `narrow`, `some`, `broad`, `very broad`, `full stack and ops` |
In the **Composites** card (Editor tab), **Add composite** twice:
* `senior_ic` — *Senior IC fit: depth and design first*: 0.4 × `python_depth` + 0.1 × `team_leadership` + 0.4 × `system_design` + 0.1 × `generalist`.
* `eng_manager` — *Engineering Manager fit: leadership first*: 0.15 × `python_depth` + 0.4 × `team_leadership` + 0.2 × `system_design` + 0.25 × `generalist`.
In **Policies**, the composites appear under *Composites* in the field list:
1. **IF** `senior_ic` ≥ 0.7 — **THEN** `continue`.
2. **IF** `eng_manager` ≥ 0.7 — **THEN** `continue`.
3. **IF** `senior_ic` \< 0.35, **+ AND** `eng_manager` \< 0.35 — **THEN** `block`.
4. **IF** `senior_ic` \< 0.7 — **THEN** `escalate`: the middle band goes to a recruiter.
Test a few resumes in the **Playground** — the *Composites* card shows both values — then **Deploy v1**.
### The decision schema [#the-decision-schema]
```json
{
"stateSchema": {
"kind": "object",
"fields": [{ "key": "resume", "type": "string", "required": true, "description": "Resume text" }]
},
"questions": [
{ "key": "python_depth", "type": "score", "instructions": "How deep is the candidate's Python experience?", "scale": ["none", "basic", "working", "strong", "expert"] },
{ "key": "team_leadership", "type": "score", "instructions": "How much team leadership has the candidate shown?", "scale": ["none", "informal", "tech lead", "manager", "manager of managers"] },
{ "key": "system_design", "type": "score", "instructions": "How strong is the candidate's system design experience?", "scale": ["none", "basic", "working", "strong", "expert"] },
{ "key": "generalist", "type": "score", "instructions": "How broad is the candidate's experience across the stack?", "scale": ["narrow", "some", "broad", "very broad", "full stack and ops"] }
],
"composites": [
{
"key": "senior_ic",
"description": "Senior IC fit: depth and design first",
"terms": [
{ "question": "python_depth", "weight": 0.4 },
{ "question": "team_leadership", "weight": 0.1 },
{ "question": "system_design", "weight": 0.4 },
{ "question": "generalist", "weight": 0.1 }
]
},
{
"key": "eng_manager",
"description": "Engineering Manager fit: leadership first",
"terms": [
{ "question": "python_depth", "weight": 0.15 },
{ "question": "team_leadership", "weight": 0.4 },
{ "question": "system_design", "weight": 0.2 },
{ "question": "generalist", "weight": 0.25 }
]
}
],
"policies": [
{ "field": "senior_ic", "on": "output", "operator": "gte", "value": 0.7, "action": "continue" },
{ "field": "eng_manager", "on": "output", "operator": "gte", "value": 0.7, "action": "continue" },
{
"field": "senior_ic",
"on": "output",
"operator": "lt",
"value": 0.35,
"and": [{ "field": "eng_manager", "on": "output", "operator": "lt", "value": 0.35 }],
"action": "block"
},
{ "field": "senior_ic", "on": "output", "operator": "lt", "value": 0.7, "action": "escalate" }
]
}
```
## Call it [#call-it]
```bash
curl -X POST https://api.dcision.io/v1/decisions/resume-screening \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: candidate-3307" \
-d '{
"state": {
"resume": "8 years building Python services (Django, FastAPI). Designed the event pipeline for 20M users at a fintech. Tech lead of 4 engineers for 2 years. Some Terraform and on-call experience."
}
}'
```
```json title="200 OK"
{
"decision_id": "dec_Hn6Vx2Kq8Zt4Wm1Lp7Rc",
"execution_id": "exec_Cs9Lw3Tq7Xm1Vn5Kd8Pz",
"schema": "resume-screening",
"version": 1,
"result": {
"python_depth": "strong",
"team_leadership": "tech lead",
"system_design": "strong",
"generalist": "broad"
},
"confidence": {
"python_depth": 0.625,
"team_leadership": 0.8167,
"system_design": 0.75,
"generalist": 0.7083
},
"scores": {
"python_depth": 4.35,
"team_leadership": 3.02,
"system_design": 4.1,
"generalist": 2.85
},
"composites": { "senior_ic": 0.7418, "eng_manager": 0.5982 },
"action": "continue",
"action_reason": { "type": "policy", "rule": 0 },
"metrics": {
"latency_ms": 391,
"engine": "jev",
"model": "jev-1.13.0",
"estimated_cost_usd": 0.0000189,
"input_tokens": 450,
"output_tokens": 47
}
}
```
How `senior_ic` was built from the weighted levels in `scores` — five levels, so each term is `(level − 1) / 4`:
```text
python_depth (4.35 − 1) / 4 = 0.8375 × 0.4
team_leadership (3.02 − 1) / 4 = 0.505 × 0.1
system_design (4.10 − 1) / 4 = 0.775 × 0.4
generalist (2.85 − 1) / 4 = 0.4625 × 0.1
senior_ic = 0.335 + 0.0505 + 0.31 + 0.04625 = 0.74175 → 0.7418 (the weights add up to 1)
```
`senior_ic` is 0.74, above 0.7: rule 0 matched and the candidate moves on as a Senior IC. Note that `result` holds the most likely level of each question, while composites use the weighted level — `python_depth` is `strong`, but with 40% on `expert` its weighted level is 4.35.
## Act on it [#act-on-it]
Rank candidates by the composite of the role you are hiring for, and let the action pick the next step:
```js
const screened = await Promise.all(resumes.map((resume) => decide("resume-screening", { resume: resume.text })));
const shortlist = screened
.map((decision, index) => ({ candidate: resumes[index], decision }))
.filter(({ decision }) => decision.action !== "block")
.sort((a, b) => b.decision.composites.senior_ic - a.decision.composites.senior_ic)
.slice(0, 10);
```
Here `decide` is a small wrapper around `POST /v1/decisions/{slug}` — see [Idempotent retries](https://docs.dcision.io/docs/guides/idempotent-retries) for a production-ready one.
## Tune it [#tune-it]
* **Change weights, not instructions.** If the top candidates don't match your expectations, adjust the weights and deploy a new version; the questions — and their calibration — stay the same.
* **Describe every level.** Short labels work, but a rubric per level is clearer to the engine. A structured level keeps the short label in the API: `{ "label": "strong", "rubric": "primary language across several projects" }`.
* **Add dimensions as questions.** A new criterion is one more question and one more term — still one call.
* **Use negative weights for red flags**, for example `− 2 × job_hopping` in a composite.
* **Threshold, don't interpolate.** A weighted level of 4.35 means "between strong and expert, closer to strong" — good for ranking and thresholds, not for reconstructing an exact number of years.
* **Keep a person in the loop.** Screening affects people: the middle band escalates, and even `continue` should lead to a human interview, not an automatic decision.
Related: [Composites](https://docs.dcision.io/docs/concepts/composites) · [Lead qualification](https://docs.dcision.io/docs/guides/lead-qualification)
---
# Intent routing
> Classify each request in one fast call and route it to the cheapest capable handler — code, a specialist LLM or a person. Built on the Customer Service Router template.
Source: https://docs.dcision.io/docs/patterns/intent-routing
Not every request needs the same handler. Some are answered by a database lookup, some need an LLM with domain-specific context, some need a person. Put Dcision in front of all of them as a fast, cheap classifier: **classify first, then route** — and only pay for the expensive handler when the request actually needs it.
Benefits: **cost** and **speed**. TypeSafe's guide: [Intent routing](https://docs.typesafe.ai/patterns/intent-routing).
## Why it works [#why-it-works]
* **A choice is a closed set.** The answer is always one of your intents — never free text to parse — with a probability for every option.
* **"None of these" is an answer.** The reserved `other` option lets the engine say a request fits no intent instead of forcing the least wrong one; Dcision then returns the fallback action.
* **One call answers the routing and the risk.** Ask for the intent and its complexity together, in parallel.
* **The expensive part runs last.** Lookups go straight to code, without any LLM; specialist models get only their own requests, with their own context.
## When to use it [#when-to-use-it]
Inbound messages and tickets, chat and voice assistants, an agent choosing its first tool, any queue where requests differ in what they need — before you pay for an LLM call.
## How it maps to Dcision [#how-it-maps-to-dcision]
| In TypeSafe's guide | In Dcision |
| --------------------------------------------- | ------------------------------------------------------------------- |
| A choice for the intent | A choice question, plus the reserved `other` |
| A score for complexity | A score question; its weighted level (`scores`) is usable in rules |
| `if intent.confidence < 0.5: human` | `minConfidence` on the intent question, or a rule on its confidence |
| `complaint` → human when complex or unsure | Rules that `escalate` the cases a person must see |
| `handle_order_status()`, `handle_with_llm(…)` | Your code routes on `result.intent` when the action is `continue` |
## Build it [#build-it]
### In the app [#in-the-app]
Open **Templates** and choose **Customer Service Router (intent routing)**. Name the decision **Customer Service Router** so its slug is `customer-service-router`, then **Create decision**.
The **State** is **Text**: the customer's message.
Two questions, answered in the same call:
| Question | Type | Answers |
| ------------ | ------ | ----------------------------------------------------------------------------------------------------------- |
| `intent` | choice | `order_status` (answered by a database lookup), `billing_question`, `technical_issue`, `complaint`, `other` |
| `complexity` | score | `simple`, `moderate`, `complex` |
In **Policies**, escalate what a person must see:
1. **IF** `intent` · *answer* · = · `complaint` — **THEN** `escalate`.
2. **IF** `intent` · *answer* · = · `technical_issue`, **+ AND** `complexity` · *weighted level* · ≥ · 2.5 — **THEN** `escalate`.
3. **IF** `complexity` · *confidence* · \< · 0.5 — **THEN** `escalate`: when the decision can't tell how hard a request is, don't automate it.
The fallback action is `escalate`, so `other` reaches a person too.
Test real messages in the **Playground**, then **Deploy v1**.
### The decision schema [#the-decision-schema]
```json
{
"stateSchema": { "kind": "text" },
"questions": [
{
"key": "intent",
"type": "choice",
"instructions": "What is the customer asking for?",
"options": [
{ "value": "order_status", "description": "where is my order, tracking, delivery date (answered by a database lookup)" },
{ "value": "billing_question", "description": "charges, invoices, refunds, payment methods" },
{ "value": "technical_issue", "description": "the product or app is not working as expected" },
{ "value": "complaint", "description": "dissatisfaction, wants to escalate or talk to a manager" }
]
},
{
"key": "complexity",
"type": "score",
"instructions": "How complex is it to resolve this request?",
"scale": ["simple", "moderate", "complex"]
}
],
"policies": [
{ "field": "intent", "on": "output", "operator": "eq", "value": "complaint", "action": "escalate" },
{
"field": "intent",
"on": "output",
"operator": "eq",
"value": "technical_issue",
"and": [{ "field": "complexity", "on": "score", "operator": "gte", "value": 2.5 }],
"action": "escalate"
},
{ "field": "complexity", "on": "confidence", "operator": "lt", "value": 0.5, "action": "escalate" }
],
"settings": { "fallbackAction": "escalate" }
}
```
## Call it [#call-it]
```bash
curl -X POST https://api.dcision.io/v1/decisions/customer-service-router \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "state": "My order #4821 was supposed to arrive yesterday. Can you tell me where it is?" }'
```
```json title="200 OK"
{
"decision_id": "dec_Lr5Tw9Mc2Qx7Hb4Nz1Kg",
"execution_id": "exec_Fv1Wd6Kp3Zs8Qm2Tx7Lh",
"schema": "customer-service-router",
"version": 1,
"result": { "intent": "order_status", "complexity": "simple" },
"confidence": { "intent": 0.925, "complexity": 0.76 },
"scores": { "complexity": 1.16 },
"action": "continue",
"action_reason": { "type": "default" },
"metrics": {
"latency_ms": 233,
"engine": "jev",
"model": "jev-1.13.0",
"estimated_cost_usd": 0.00000966,
"input_tokens": 230,
"output_tokens": 21
}
}
```
No rule matched: a simple order-status question that code can answer without any LLM.
## Act on it [#act-on-it]
```ts
type Intent = "order_status" | "billing_question" | "technical_issue" | "complaint" | "other";
async function route(message: Message) {
const response = await fetch("https://api.dcision.io/v1/decisions/customer-service-router", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.DCISION_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ state: message.text }),
});
if (!response.ok) return humanQueue.add(message); // errors: a person decides
const decision = await response.json();
if (decision.action !== "continue") return humanQueue.add(message, { reason: decision.action_reason });
switch (decision.result.intent as Intent) {
case "order_status":
return orders.replyWithTracking(message); // deterministic code, no LLM
case "billing_question":
return llm.answer(message, BILLING_SPECIALIST);
case "technical_issue":
return llm.answer(message, SUPPORT_SPECIALIST); // complex ones were escalated by rule 1
default:
return humanQueue.add(message);
}
}
```
## Tune it [#tune-it]
* **Gate the intent itself.** TypeSafe's version sends anything below 0.5 intent confidence to a person: set `"minConfidence": 0.5` on `intent` to do the same.
* **Make the boundaries explicit.** Describe each intent by what it covers and what it doesn't — option descriptions accept JSON rubrics such as `{ "what": "…", "not_for": "…", "examples": ["…"] }`. See [Questions](https://docs.dcision.io/docs/concepts/questions#structured-instructions-and-criteria).
* **Check the option order.** Jev can lean toward the option listed first. Reorder the options in the Playground and check that the answers stay the same.
* **Describe `other`** when "none of these" has a meaning for you — "not about an order, a bill or the app" — so off-topic messages reach the fallback action.
* **Grow the set as you learn.** A choice holds up to 254 options plus `other`. Watch the `other_option` reasons in **Executions** to find the intents you are missing.
* **Route tools the same way.** The [Agent routing](https://docs.dcision.io/docs/guides/agent-routing) template applies this pattern to an AI agent's first tool.
Related: [Confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing) · [Support routing](https://docs.dcision.io/docs/guides/support-routing) · [Spam detection](https://docs.dcision.io/docs/guides/spam-detection)
---
# Destinations overview
> What happens after a decision — per option, score level, probability threshold or final action — fixed replies, LLM answers, your agent, workflows, signed webhooks, HTTP requests and functions.
Source: https://docs.dcision.io/docs/destinations
A decision answers **what**: `result`, `confidence` and the `action`. **Destinations** say **what happens next** for each result — like the routes of a phone tree. Attach one or more destinations to an option (`route = sales`), a score level (`priority = critical`), a probability threshold (`purchase_intent ≥ 0.8`) or a final action (`escalate`), and every call that lands there answers the caller, hands the case off or tells your code what to run.
Destinations are part of the [decision schema](https://docs.dcision.io/docs/concepts/decision-schema): they are edited in the draft, tested in the Playground and reach production when you **deploy**, like questions and policies.
```text
state ──▶ answers ──▶ composites ──▶ policies ──▶ action ──▶ destinations
├─ answer the caller: fixed reply · LLM · agent
├─ hand off: workflow · webhook · API request
└─ your code: function
```
## Destination types [#destination-types]
| Type | In the app | Who runs it | What happens |
| ---------- | ----------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `reply` | Fixed reply | Dcision renders it | Returns a ready message — text and buttons — in the response. No model call. |
| `llm` | LLM | Dcision calls OpenRouter or Vercel AI Gateway with **your** key | A model answers with the route's prompt; its text comes back in the response. |
| `agent` | Agent | Dcision calls your agent's endpoint | `sync`: the agent's reply comes back in the response. `async`: a hand-off, delivered in the background. |
| `workflow` | Workflow | Dcision, in the background | Triggers n8n, Make, Zapier, Pipedream or any workflow URL with the route's variables. |
| `webhook` | Webhook | Dcision, in the background | POSTs the signed decision result to your URL. |
| `http` | API request | Dcision, in the background | Calls any API: method, URL, headers and a JSON body with variables and secrets. |
| `function` | Function | Your code | The response names a function and its params; the SDKs call your handler. |
The editor groups them the same way: **Answer the caller** (fixed reply, LLM, agent), **Hand off** (workflow, webhook, API request) and **Your code** (function).
## Which one to use [#which-one-to-use]
* **Answer an end user right away with a known text** — a confirmation, a link, quick-reply buttons: a [fixed reply](https://docs.dcision.io/docs/destinations/fixed-replies). It costs nothing and adds no latency.
* **Answer with generated text, specific to the route** — "you are the billing team…": an [LLM answer](https://docs.dcision.io/docs/destinations/llm), with your OpenRouter or Vercel AI Gateway key.
* **Let your own agent take over**, with its tools and memory: an [agent](https://docs.dcision.io/docs/destinations/agents) — `sync` to return its reply now, `async` to hand off.
* **Start a no-code automation** in n8n, Make, Zapier or Pipedream: a [workflow](https://docs.dcision.io/docs/destinations/workflows).
* **Notify your backend** with a signed event: a [webhook](https://docs.dcision.io/docs/destinations/webhooks).
* **Call a third-party API directly** — create a CRM lead, open a ticket, post to Slack — with a token kept in [workspace secrets](https://docs.dcision.io/docs/destinations/secrets): an [API request](https://docs.dcision.io/docs/destinations/http-requests).
* **Run code in the process that called the decision**: a [function](https://docs.dcision.io/docs/destinations/functions), dispatched by the [SDKs](https://docs.dcision.io/docs/sdks).
A decision can mix them: `route = sales` can return a fixed reply **and** trigger a CRM workflow **and** tell your code to assign an owner.
## What a call returns [#what-a-call-returns]
Destinations are selected after the action is chosen. The response gets a `destinations` array with one entry per destination that fired, in schema order:
* **Fixed replies** and **functions** are returned at once.
* **LLM answers** and **agents in `sync` mode** are called while the caller waits — in parallel, each with its own timeout — and their answers are returned.
* **Webhooks, API requests, workflows** and **agents in `async` mode** are queued and delivered in the background, with retries: the response returns their `delivery_id`.
```json title="200 OK — Lead Qualification with destinations"
{
"decision_id": "dec_3fKq9ZtW1mXcV7bN2pLa",
"execution_id": "exec_8HsT2kQw9ZyR4vMn1cXe",
"schema": "lead-qualification",
"version": 4,
"result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
"confidence": { "purchase_intent": 0.8824, "priority": 0.69, "route": 0.85 },
"scores": { "priority": 2.81 },
"composites": { "lead_score": 0.8414 },
"action": "continue",
"action_reason": { "type": "default" },
"destinations": [
{
"key": "sales_reply",
"type": "reply",
"text": "Thanks! Someone from our sales team will contact you today.",
"buttons": ["Book a demo", "See pricing"]
},
{ "key": "sales_crm", "type": "workflow", "status": "queued", "delivery_id": "dlv_8kJx2mQp4LzN7vR1tY6w" },
{
"key": "hot_lead",
"type": "function",
"function": "assignToSales",
"params": { "message": "We need pricing for 500 users and want to start next month." }
}
],
"metrics": {
"latency_ms": 418,
"engine": "jev",
"model": "jev-1.13.0",
"estimated_cost_usd": 0.00001575,
"input_tokens": 375,
"output_tokens": 36
}
}
```
`destinations` is present whenever the decision has destinations — `[]` when none fired — and absent otherwise. Every entry is described in [Run a decision](https://docs.dcision.io/docs/api/run-decision#destinations).
A destination that can't run — a missing param, an LLM without a key, an unreachable agent, a full queue — shows up on its own entry (`skipped` or `failed`). The decision still answers `200` with its result and action, and it is billed like any other.
## Set them up in the editor [#set-them-up-in-the-editor]
### Pick the answer [#pick-the-answer]
In the editor's **Questions** card, every choice option has a **Destination** selector on its own row. It shows the type of the destination already attached (with `+1`, `+2`… when there are more); on the reserved `other` option it reads *Fallback · escalate* — the decision's [fallback action](https://docs.dcision.io/docs/concepts/settings#fallbackaction) — until you attach one.
* **Score questions** have a **Destination per level** row: one selector per level.
* **Probability questions** have **Destination when … ≥ …**: a threshold on the probability of *yes*, 0.8 by default.
### Choose the type [#choose-the-type]
Click the selector to open the panel *When route = sales*. It lists the destinations of that answer, editable in place; **Add destination** shows the seven types as cards, grouped into *Answer the caller*, *Hand off* and *Your code*.
### Configure it [#configure-it]
Each destination has a **key** (snake_case, unique in the decision), an **Active** switch and:
* **When** — the conditions that make it fire. The panel already holds `route = sales`; set a **Minimum confidence** for this route in percent, add more conditions, or require a final action. See [Triggers](https://docs.dcision.io/docs/destinations/triggers).
* The type's settings — the reply text, the model and prompt, the agent's endpoint, the URL…
* **Params** — the variables it uses, from a field or a fixed value. See [Params and templates](https://docs.dcision.io/docs/destinations/params-and-templates).
### Test and deploy [#test-and-deploy]
Run the draft in the [Playground](https://docs.dcision.io/docs/destinations/testing): destinations are previewed — nothing is sent — until you switch on **Run destinations for real**. Then deploy: API calls fire the destinations of the new version.
The **Destinations** card, after **Policies** in the editor, lists every destination of the decision (up to 20) and adds ones that aren't tied to a single answer — for example *every decision*, or *action is `escalate`*.
### In the visual builder [#in-the-visual-builder]
The builder's palette has a **Destinations** group with the seven types. Destinations appear as nodes after the **Output** node, connected by an edge labeled with the trigger (`route = sales · route confidence ≥ 0.8`) — dashed when the destination is switched off. Choice options show a small count of the destinations attached to them. Select a node to edit it in the inspector.
The palette, each of its groups and the inspector are **collapsible**; the layout is remembered in your browser, and selecting a node reopens a collapsed inspector.
## In the schema [#in-the-schema]
```json title="decision.json (excerpt)"
{
"destinations": [
{
"key": "sales_reply",
"type": "reply",
"when": { "conditions": [{ "field": "route", "on": "output", "operator": "eq", "value": "sales" }] },
"reply": { "text": "Thanks! Someone from our sales team will contact you today.", "buttons": ["Book a demo", "See pricing"] }
},
{
"key": "sales_crm",
"type": "workflow",
"when": {
"conditions": [
{ "field": "route", "on": "output", "operator": "eq", "value": "sales" },
{ "field": "route", "on": "confidence", "operator": "gte", "value": 0.8 }
]
},
"params": {
"message": { "from": "state.message", "required": true },
"company_size": { "from": "state.company_size" },
"lead_score": { "from": "composites.lead_score" }
},
"workflow": { "platform": "n8n", "url": "{{secrets.N8N_LEADS_WEBHOOK}}", "name": "New sales lead" }
},
{
"key": "hot_lead",
"type": "function",
"when": { "conditions": [{ "field": "purchase_intent", "on": "output", "operator": "gte", "value": 0.8 }] },
"params": { "message": { "from": "state.message" } },
"function": { "name": "assignToSales" }
},
{
"key": "escalation_alert",
"type": "http",
"when": { "actions": ["escalate"] },
"http": {
"method": "POST",
"url": "{{secrets.SLACK_WEBHOOK}}",
"body": "{\"text\": \"A lead needs a person: {{decision.slug}} execution {{execution.id}}\"}"
}
}
]
}
```
| Field | Type | Default | Description |
| ------------------------------------------------------------------ | ------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `key` | string | — | Unique among the decision's destinations; snake_case, up to 48 characters. It identifies the entry in responses, deliveries and the Overview. |
| `description` | string | — | Optional, up to 2,000 characters. |
| `enabled` | boolean | `true` | Switched-off destinations never fire. |
| `when` | object | every decision | `conditions` (AND) and `actions` — see [Triggers](https://docs.dcision.io/docs/destinations/triggers). |
| `type` | string | — | `reply`, `llm`, `agent`, `workflow`, `webhook`, `http` or `function`. |
| `params` | object | `{}` | Up to 30 variables — see [Params and templates](https://docs.dcision.io/docs/destinations/params-and-templates). |
| `reply`, `llm`, `agent`, `workflow`, `webhook`, `http`, `function` | object | — | The settings of the type: exactly the block named in `type`. |
Saving or deploying a schema with an invalid destination fails with `422 INVALID_SCHEMA` and a path such as `destinations.1.workflow.url` — the editor links it with *Go to error*.
## Roles [#roles]
| Action | Who |
| ------------------------------------------------------------------------------ | ------------------------------------------- |
| Add, edit and remove destinations (they are part of the decision), deploy them | Members, Admins and the Owner |
| Resend a failed delivery | Members, Admins and the Owner |
| See deliveries and their status | Everyone in the workspace, Viewers included |
| See the names of workspace secrets | Members, Admins and the Owner |
| Add, replace or delete secrets; reveal or rotate the signing secret | Admins and the Owner |
See [Team, roles and account](https://docs.dcision.io/docs/team-and-roles).
## Next steps [#next-steps]
---
# Destination triggers
> When a destination fires — conditions on answers, confidence, weighted levels and composites, final actions, a minimum confidence per route, score levels, probability thresholds — and in which order.
Source: https://docs.dcision.io/docs/destinations/triggers
Every destination has a `when` object. It fires when the decision's **final action** is one of `when.actions` (if you set any) **and** every condition in `when.conditions` holds:
```json
{
"conditions": [
{ "field": "route", "on": "output", "operator": "eq", "value": "sales" },
{ "field": "route", "on": "confidence", "operator": "gte", "value": 0.8 }
],
"actions": ["continue"]
}
```
| Property | Type | Default | Description |
| ------------ | ----- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `conditions` | array | `[]` | Up to 5 conditions, all of which must hold. Same shape as a [policy condition](https://docs.dcision.io/docs/concepts/policies-and-actions#policy-rules): `field`, `on` (`output`, `confidence` or `score`), `operator` and `value`. |
| `actions` | array | — | Fire only when the final action is one of these: `continue`, `block`, `escalate`, `fallback`. |
An empty `when` fires after **every** decision.
## Per option [#per-option]
The most common trigger, and what the **Destination** selector of an option creates: the question's answer equals the option.
```json
{ "conditions": [{ "field": "route", "on": "output", "operator": "eq", "value": "sales" }] }
```
Use `neq` for "every route except spam". The reserved `other` option is an answer like any other: a destination on `route = other` fires when no option fits — while the decision's [fallback action](https://docs.dcision.io/docs/concepts/settings#fallbackaction) (`escalate` by default) tells your code that a person should look.
## Minimum confidence per route [#minimum-confidence-per-route]
The **Minimum confidence** field of a destination adds a `confidence ≥` condition on the same question, so the route only fires when the engine is sure enough:
```json
{
"conditions": [
{ "field": "route", "on": "output", "operator": "eq", "value": "sales" },
{ "field": "route", "on": "confidence", "operator": "gte", "value": 0.8 }
]
}
```
It is independent of the question's own `minConfidence`, which changes the **action**: a low-confidence answer gets the fallback action and `action_reason.type = "low_confidence"`, whatever the destinations. Combine both, or give each route its own threshold — a refund route can demand 0.9 while a "send an article" route accepts 0.6. See [confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing).
## Per score level [#per-score-level]
A score question's selectors create a condition on the most likely level:
```json
{ "conditions": [{ "field": "priority", "on": "output", "operator": "eq", "value": "critical" }] }
```
Levels can also be compared by number (1 = the first level) with any operator — `"operator": "gte", "value": 3` is *high or critical* on a four-level scale — or by **weighted level** with `"on": "score"`, which can land between levels: `"on": "score", "operator": "gte", "value": 3.5`.
## Probability thresholds [#probability-thresholds]
**Destination when … ≥ …** on a probability question creates a threshold on the probability of *yes*, 0.8 by default:
```json
{ "conditions": [{ "field": "purchase_intent", "on": "output", "operator": "gte", "value": 0.8 }] }
```
## Composites and other answers [#composites-and-other-answers]
A condition can use any question or [composite](https://docs.dcision.io/docs/concepts/composites) of the decision, so a destination doesn't have to follow the question its panel belongs to: `lead_score ≥ 0.75` sends hot leads to a workflow whatever the route.
```json
{
"conditions": [
{ "field": "lead_score", "on": "output", "operator": "gte", "value": 0.75 },
{ "field": "route", "on": "output", "operator": "neq", "value": "spam" }
]
}
```
## Final actions [#final-actions]
`actions` ties a destination to the verdict instead of an answer — *every `escalate` posts to Slack*, *every `block` opens a moderation ticket*:
```json
{ "actions": ["escalate"] }
```
With both `conditions` and `actions`, both must hold.
## Validation [#validation]
Conditions follow the rules of policy conditions, and they are checked when you save or deploy — `422 INVALID_SCHEMA` otherwise:
* the `field` must be a question or a composite of the decision;
* choice answers only support `eq` and `neq`, and the value must be one of the options;
* probabilities and confidence are numbers from 0 to 1 — `0.7`, not `70`;
* score levels go from 1 to the number of levels, or use a level label;
* composites go from −1 to 1 (0 to 1 with positive weights).
## Evaluation [#evaluation]
After the action is chosen, Dcision walks the destinations **in schema order**:
1. A destination that is switched off (`"enabled": false`) is ignored.
2. Its `actions` and `conditions` are checked. Destinations that don't match leave no entry.
3. Its [params](https://docs.dcision.io/docs/destinations/params-and-templates) are resolved. When a param marked `required` has no value, the destination doesn't fire: its entry says `"status": "skipped", "reason": "missing_param"` and names the `param`.
4. At most **10 destinations fire per execution**, and at most **3** of them can make the caller wait — LLM answers and agents in `sync` mode. Beyond that, the entry says `"status": "skipped", "reason": "limit"`.
5. The rest run: replies and functions are returned, LLM and `sync` agent calls are awaited in parallel, and background deliveries are queued.
## When the engine fails [#when-the-engine-fails]
With [`onEngineError: "fallback"`](https://docs.dcision.io/docs/concepts/settings#onengineerror), an engine failure answers `200` with the fallback action and no answers. Then:
* destinations **with conditions don't fire** — there is no answer to compare;
* destinations **without conditions** do, filtered by `actions` when set — the right place for an alert such as *escalate → notify the on-call person*;
* `result.*`, `confidence.*`, `scores.*` and `composites.*` variables are `null`.
Engine-error fallbacks aren't stored for idempotency: retrying the call with the same `Idempotency-Key` runs the decision again, and its destinations fire again with new delivery IDs. Deduplicate such alerts on your side — for example by your own record ID.
## Replays [#replays]
An [idempotent replay](https://docs.dcision.io/docs/api/idempotency) returns the stored response — including its `destinations` entries and their delivery IDs — and fires nothing again. The SDKs, though, dispatch the [functions](https://docs.dcision.io/docs/destinations/functions) of a replayed response again, so make function handlers idempotent.
---
# Params and templates
> Map values into a destination — params from a field or a fixed value, required params, {{…}} variables in URLs, headers, bodies and texts, and how each place encodes them for you.
Source: https://docs.dcision.io/docs/destinations/params-and-templates
Destinations work with two tools: **params**, the named values a destination carries, and **templates**, the `{{…}}` variables you write in its URLs, headers, bodies and texts.
## Params [#params]
```json
{
"params": {
"message": { "from": "state.message", "required": true },
"route": { "from": "result.route" },
"lead_score": { "from": "composites.lead_score" },
"team": { "value": "inbound" }
}
}
```
A param takes its value **from a field** of the run (`from`) or is a **fixed value** (`value`): a string of up to 500 characters, a number, `true`, `false` or `null`. A destination has up to 30 params, named with letters, digits and `_` — up to 48 characters, not starting with a digit.
### Sources [#sources]
| `from` | Value |
| ----------------------- | --------------------------------------------------------------------------- |
| `state` | The whole state: an object, a string or an array. |
| `state.` | A field declared in an object state schema. |
| `result.` | The answer: an option, a level label or a probability. |
| `confidence.` | The answer's confidence, 0 to 1. |
| `scores.` | The weighted level of a score question. |
| `composites.` | A composite's value. |
| `action` | The final action: `continue`, `block`, `escalate` or `fallback`. |
| `decision.slug` | The decision's slug. |
| `decision.version` | The version that answered — `null` in the Playground, which runs the draft. |
| `execution.id` | The run's `exec_…` ID. |
Values keep their JSON type: `confidence.route` is a number, `state` an object. A value that doesn't exist — an optional field the caller didn't send, any answer after an [engine-error fallback](https://docs.dcision.io/docs/destinations/triggers#when-the-engine-fails) — is `null`.
### Required params [#required-params]
Mark a param `required` when the destination makes no sense without it — a CRM lead without an e-mail. When it resolves to `null` or an empty string, the destination **doesn't fire** and the response says why:
```json
{ "key": "create_lead", "type": "http", "status": "skipped", "reason": "missing_param", "param": "email" }
```
### Where params go [#where-params-go]
| Type | Params are… |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `webhook` | sent in the event, under `data.params`. |
| `workflow` | sent as top-level JSON fields — what n8n, Make and Zapier map directly. |
| `http` | the JSON body of `POST`, `PUT` and `PATCH` requests without a body template, and the query string of `GET` and `DELETE` requests whose URL doesn't use them. |
| `agent` | sent in the request body, under `params`. |
| `function` | passed to your handler as its first argument. |
| `reply`, `llm` | only used through `{{params.…}}` in their texts. |
Use params for anything that reaches a third party: a webhook or a workflow never receives the state itself, only the params you map — send what the receiver needs and nothing more.
## Templates [#templates]
Write `{{` and `}}` around a variable — spaces inside are allowed: `{{ params.awb }}`.
| Variable | Value |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `{{params.}}` | A param of this destination. |
| `{{state}}`, `{{state.}}` | The state, or a declared field of an object state. |
| `{{result.}}`, `{{confidence.}}`, `{{scores.}}` | An answer, its confidence, a weighted level. |
| `{{composites.}}` | A composite's value. |
| `{{action}}`, `{{decision.slug}}`, `{{decision.version}}`, `{{execution.id}}` | The run. |
| `{{delivery.id}}` | The `dlv_…` ID of this delivery — the same on every attempt. |
| `{{secrets.}}` | A [workspace secret](https://docs.dcision.io/docs/destinations/secrets). |
The editor's **Insert…** menu next to each field lists the variables you can use there.
### Encoding is automatic [#encoding-is-automatic]
You never escape anything by hand: Dcision encodes each value for the place it goes.
| Where | How values are inserted | Secrets |
| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| URLs — `webhook.url`, `http.url`, `agent.url`, `workflow.url` | URL-encoded. A secret is inserted as-is, so it can hold a whole URL. | Allowed |
| Header values — `http.headers`, `agent.headers` | As text, with line breaks replaced by spaces — a value can't add a header. | Allowed |
| `http.body` | As JSON — see below. | Allowed |
| Texts — `reply.text`, `reply.buttons`, `llm.instructions`, `llm.input`, `agent.instructions` | As text, line breaks kept. | Not allowed: the text is returned to the caller or sent to a model. |
As text, strings are inserted as they are, numbers and booleans are printed, objects and arrays become JSON and `null` becomes an empty string.
### JSON bodies [#json-bodies]
An `http` body template is JSON with variables, and a variable's place decides how it is written:
* **Outside quotes**, it becomes a JSON value — a string with its quotes, a number, `true`, `null`, an object.
* **Inside quotes**, it becomes text inside that string, escaped as needed.
For a decision whose object state has `email` and `company_size` fields, with a param `email` taken from `state.email`:
```text title="Body template"
{
"email": {{params.email}},
"company_size": {{state.company_size}},
"summary": "Route {{result.route}} at {{confidence.route}} — lead score {{composites.lead_score}}",
"lead": {{state}}
}
```
```json title="Body sent"
{
"email": "ana@acme.com",
"company_size": 500,
"summary": "Route sales at 0.85 — lead score 0.8414",
"lead": { "message": "We need pricing for 500 users.", "email": "ana@acme.com", "company_size": 500 }
}
```
So write `{{state.company_size}}` — not `"{{state.company_size}}"` — to send the number `500` instead of the text `"500"`. `{{params.email}}` needs no quotes either: its value is a string, so it is written with its quotes.
The body must be valid JSON once the variables are filled in: the editor checks it with sample values when you save, and an invalid template is refused with *"The body must be JSON once variables are filled in…"*. A body is up to 10,000 characters. Leave it empty on `POST`, `PUT` and `PATCH` to send the params as a JSON object; `GET` and `DELETE` requests have no body.
### Template checks [#template-checks]
Templates are validated when you save or deploy:
* every variable must exist: `{{params.awb}}` needs a param named `awb`, `{{state.company_size}}` a field of an object state, `{{result.route}}` a question named `route`;
* secret names are UPPER_SNAKE_CASE, such as `{{secrets.CRM_TOKEN}}`;
* every `{{` needs its `}}`, with one variable inside;
* secrets can't be used in texts.
A missing secret isn't a schema error — secrets change without a deploy — but a delivery that references one fails until it is set. See [Workspace secrets](https://docs.dcision.io/docs/destinations/secrets).
---
# Fixed replies
> Return a ready-made message — text and optional buttons, with variables — in the API response for a given result, instantly and without calling any model.
Source: https://docs.dcision.io/docs/destinations/fixed-replies
A **fixed reply** is the simplest destination: a message you write once, returned in the response whenever its trigger matches. Dcision fills in the variables and returns it — no model call, no extra latency, nothing to pay.
Use it for confirmations, links and next steps that don't need to be generated: *"Thanks! Someone from our sales team will contact you today."*, with quick-reply buttons for a chat widget, WhatsApp or an e-mail template.
## Configure it [#configure-it]
```json
{
"key": "sales_reply",
"type": "reply",
"when": { "conditions": [{ "field": "route", "on": "output", "operator": "eq", "value": "sales" }] },
"params": { "seats": { "from": "state.company_size" } },
"reply": {
"text": "Thanks! Our sales team will contact you today about {{params.seats}} seats.",
"buttons": ["Book a demo", "See pricing"]
}
}
```
| Field | Type | Default | Description |
| --------------- | ---------------- | ------- | --------------------------------------------------------------------------------------------------------- |
| `reply.text` | string | — | The message, 1 to 4,000 characters. Variables allowed; line breaks are kept. |
| `reply.buttons` | array of strings | `[]` | Up to 10 labels of up to 80 characters each — quick replies or list items for your UI. Variables allowed. |
Texts use the [text encoding](https://docs.dcision.io/docs/destinations/params-and-templates#encoding-is-automatic): values are inserted as text, and a missing value becomes an empty string. Secrets can't be used in a reply — it is returned to whoever called the API.
## In the response [#in-the-response]
```json
{
"key": "sales_reply",
"type": "reply",
"text": "Thanks! Our sales team will contact you today about 500 seats.",
"buttons": ["Book a demo", "See pricing"]
}
```
A reply entry has no `status`: it is always rendered. Show `text` to your user and `buttons` as quick replies; when a button is pressed, send its label back as the next state of your conversation.
## Good to know [#good-to-know]
* Replies count toward the 10 destinations that can fire per execution, but not toward the 3 that make the caller wait.
* A decision can return several replies — for example one per question — in schema order.
* In the Playground, a reply is rendered as a chat bubble with its buttons, in previews too.
* Need a different answer for every message? Use an [LLM answer](https://docs.dcision.io/docs/destinations/llm) or your own [agent](https://docs.dcision.io/docs/destinations/agents) on that route instead.
---
# LLM answers
> Let a model answer for a route — Dcision calls OpenRouter or Vercel AI Gateway with your workspace's own key, the route's prompt as the system message and the state as input.
Source: https://docs.dcision.io/docs/destinations/llm
An **LLM** destination generates the answer for a route. When it fires, Dcision sends a chat request to the model you picked — the route's prompt as the **system** message, the state (or a template of your choice) as the **user** message — and returns the model's text in the response.
It is how one decision can route *and* answer: Jev decides that a message is about billing, and the billing prompt writes the reply.
## Your key, your provider [#your-key-your-provider]
LLM answers run on **your own** provider key — never on Dcision's engine key:
| `provider` | Calls | Key |
| ---------------------- | ------------------------------------------------------- | -------------------------- |
| `openrouter` (default) | `POST https://openrouter.ai/api/v1/chat/completions` | Your OpenRouter key |
| `vercel` | `POST https://ai-gateway.vercel.sh/v1/chat/completions` | Your Vercel AI Gateway key |
Save the key in **Settings → Engine → Provider keys** — the same keys used to [bring your own key](https://docs.dcision.io/docs/concepts/engines-and-byok#bring-your-own-key) for Jev; Admins and the Owner can add them. Saving a key doesn't change the engine: decisions can keep running on Dcision's engine key while their LLM destinations use yours. The provider bills the tokens to your account; Dcision doesn't charge for them, and the decision itself is billed as usual.
## Configure it [#configure-it]
```json
{
"key": "sales_llm",
"type": "llm",
"when": { "conditions": [{ "field": "route", "on": "output", "operator": "eq", "value": "sales" }] },
"llm": {
"provider": "openrouter",
"model": "openai/gpt-5-mini",
"instructions": "You are Acme's sales team. Answer the lead in two sentences, say that a person will follow up today and never quote prices.",
"input": "{{state.message}}",
"maxTokens": 300,
"temperature": 0.3,
"timeoutMs": 15000
}
}
```
| Field | Type | Default | Description |
| -------------- | ------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `provider` | string | `openrouter` | `openrouter` or `vercel`. |
| `model` | string | — | The provider's model ID, such as `openai/gpt-5-mini` — letters, digits and `. _ : / -`, up to 120 characters. The editor suggests a few. |
| `instructions` | string | — | The route's prompt, sent as the system message: 1 to 8,000 characters, with variables. |
| `input` | string | the whole state | The user message: up to 8,000 characters, with variables. Empty sends the state as text — a string as-is, an object or a list as JSON. |
| `maxTokens` | integer | `512` | 16 to 2,048. |
| `temperature` | number | `0.3` | 0 to 2. |
| `timeoutMs` | integer | `20000` | 1,000 to 25,000: how long Dcision waits for the model. |
Prompts and inputs use the [text encoding](https://docs.dcision.io/docs/destinations/params-and-templates#encoding-is-automatic); secrets can't be used in them.
## In the response [#in-the-response]
```json title="Answered"
{
"key": "sales_llm",
"type": "llm",
"status": "completed",
"text": "Thanks for reaching out! Someone from our sales team will contact you today to talk about your 500 users.",
"model": "openai/gpt-5-mini",
"usage": { "input_tokens": 61, "output_tokens": 27 }
}
```
`model` is the model the provider reports, and `usage` the tokens it counted — the provider's numbers, separate from the decision's own `metrics`.
When the call can't be made or fails, the entry says so and the decision still answers:
```json title="Failed"
{ "key": "sales_llm", "type": "llm", "status": "failed", "error": "Add your OpenRouter key in Settings → Engine to use LLM replies." }
```
| `error` | Cause |
| ------------------------------------------------------------------ | ---------------------------------------------------------- |
| *Add your OpenRouter key in Settings → Engine to use LLM replies.* | No key saved for the provider (the message names it). |
| *OpenRouter answered 401: …* | The provider refused the request; its own message follows. |
| *OpenRouter returned an empty answer.* | No text in the provider's answer. |
| *No answer from OpenRouter within 15 s.* | `timeoutMs` elapsed. |
| *Couldn't reach OpenRouter.* | A network error. |
LLM calls are made once — they aren't retried — and failures appear in the decision's [Overview](https://docs.dcision.io/docs/guides/overview-and-calibration) as failed destinations.
## Latency [#latency]
The caller **waits** for LLM answers: they run after the decision, in parallel with the other destinations that wait (agents in `sync` mode), so the response arrives after the slowest of them. A decision can make at most **3** such calls per execution; more are skipped with `"reason": "limit"`.
Give your HTTP client a timeout that covers the decision's `timeoutMs` **plus** the longest destination `timeoutMs` — for example 5 s + 15 s + a margin. The [SDKs](https://docs.dcision.io/docs/sdks) wait 30 seconds per attempt by default. The response's `metrics.latency_ms` measures the decision only: it doesn't include the time spent on these calls.
## In the Playground [#in-the-playground]
Previews show the prompt — provider, model, the rendered instructions and message — without calling the model. Switch on **Run destinations for real** to get a real answer; see [Testing destinations](https://docs.dcision.io/docs/destinations/testing).
## Data [#data]
The input — the whole state unless you set `input` — is sent to the provider you chose, under your account and its terms. Send only what the answer needs: `"input": "{{state.message}}"` instead of the whole state.
---
# Agents
> Hand a result to your own agent endpoint with the route's instructions and tools — wait for its reply in the response, or hand off in the background with retries.
Source: https://docs.dcision.io/docs/destinations/agents
An **agent** destination calls an endpoint you run — your support agent, a LangGraph or Agents SDK service, an internal assistant — with the state, the route's **instructions** and the **tools** it may use on that route. Dcision decides *which* agent and *with what brief*; your agent does the work.
| `mode` | What happens | In the response |
| ---------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `sync` (default) | Dcision calls the agent while the caller waits and extracts its reply. | `"status": "completed"` with `reply`, or `"status": "failed"` with `error`. |
| `async` | A hand-off: the request is queued and delivered in the background, with retries. | `"status": "queued"` with `delivery_id`. |
## Configure it [#configure-it]
```json
{
"key": "shipping_agent",
"type": "agent",
"when": { "conditions": [{ "field": "department", "on": "output", "operator": "eq", "value": "shipping" }] },
"params": { "awb": { "from": "state.awb", "required": true } },
"agent": {
"url": "https://agents.example.com/support",
"headers": [{ "name": "Authorization", "value": "Bearer {{secrets.AGENT_TOKEN}}" }],
"instructions": "Help with the shipment {{params.awb}}. Confirm the address before rescheduling.",
"tools": ["track_package", "reschedule_delivery"],
"mode": "sync",
"replyPath": "data.answer",
"timeoutMs": 20000
}
}
```
| Field | Type | Default | Description |
| -------------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------- |
| `url` | string | — | Your endpoint: `https://…`, or `{{secrets.NAME}}` holding the whole URL. Up to 2,048 characters, with variables. |
| `headers` | array | `[]` | Up to 20 `{ "name", "value" }` pairs; values up to 2,048 characters, with variables and secrets. |
| `instructions` | string | — | The route's prompt, sent as `instructions`: up to 8,000 characters, with variables, no secrets. |
| `tools` | array | `[]` | Up to 50 tool names your agent knows — letters, digits and `_ . : -`, up to 64 characters each. |
| `mode` | string | `sync` | `sync` or `async`. |
| `replyPath` | string | — | `sync` only: where the reply is in the agent's JSON answer, as a dot path such as `data.answer`. |
| `timeoutMs` | integer | `20000` | 1,000 to 25,000 — `sync` only. |
## The request your agent receives [#the-request-your-agent-receives]
```http
POST /support HTTP/1.1
Host: agents.example.com
Content-Type: application/json
Authorization: Bearer agt_live_51Hq…
User-Agent: Dcision-Destinations/1.0 (+https://docs.dcision.io/destinations)
Dcision-Delivery: dlv_Hq2Lm9Xc4Rv1Tz8Wn3Bk
Dcision-Attempt: 1
Dcision-Event: decision.completed
Dcision-Signature: t=1759658400,v1=6f1c2b…
Idempotency-Key: dlv_Hq2Lm9Xc4Rv1Tz8Wn3Bk
```
```json title="Body"
{
"input": { "message": "Where is my package? It was due yesterday.", "awb": "4471-2210" },
"instructions": "Help with the shipment 4471-2210. Confirm the address before rescheduling.",
"tools": ["track_package", "reschedule_delivery"],
"params": { "awb": "4471-2210" },
"decision": {
"decision": "support-desk",
"decision_id": "dec_7Wq2Lm9Xc4Rv1Tz8Wn3B",
"version": 2,
"execution_id": "exec_Vn4Kp8Wd2Lq6Tz1Xc9Bs",
"result": { "department": "shipping", "urgency": "medium" },
"confidence": { "department": 0.92, "urgency": 0.71 },
"action": "continue",
"action_reason": { "type": "default" },
"destination": "shipping_agent",
"delivery_id": "dlv_Hq2Lm9Xc4Rv1Tz8Wn3Bk",
"livemode": true
}
}
```
| Field | Description |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `input` | The whole state, as the caller sent it. |
| `instructions` | The route's prompt with its variables filled in; `null` when the destination has none. |
| `tools` | The route's tool names. |
| `params` | The destination's [params](https://docs.dcision.io/docs/destinations/params-and-templates). |
| `decision` | What the decision answered — slug and IDs, `version` (`null` in the Playground), `result`, `confidence`, `action`, `action_reason` — plus the destination's key, the `delivery_id` and `livemode` (`false` for test keys and the Playground). |
The request is signed like a [webhook](https://docs.dcision.io/docs/destinations/webhooks#verify-the-signature): verify `Dcision-Signature` over the raw body with `verifySignature` — the body is an agent request, not a webhook event. In `async` mode, deduplicate on `Idempotency-Key`, the delivery ID, which stays the same on every attempt.
## The answer Dcision expects [#the-answer-dcision-expects]
In `sync` mode, answer `2xx` within `timeoutMs`. Dcision takes the reply from:
1. the field at `replyPath`, when you set one;
2. otherwise the first of these top-level fields that exists: `reply`, `output_text`, `output`, `text`, `message`, `content`;
3. or the whole body, when the answer isn't JSON.
A string is used as-is; other values are returned as JSON text.
```json title="In the response"
{
"key": "shipping_agent",
"type": "agent",
"status": "completed",
"reply": "Your package is at the local depot. I can deliver it tomorrow between 9 and 12 — shall I book it?",
"response": { "data": { "answer": "Your package is at the local depot. I can deliver it tomorrow between 9 and 12 — shall I book it?", "session": "s_9f2c" } }
}
```
`response` is the agent's whole answer, parsed when it is JSON, and `null` when it is larger than 16,000 characters. `reply` is `null` when no reply field was found — set **Reply field** (`replyPath`) in the destination.
A `sync` call is made **once**: no retries, redirects aren't followed, and the answer is read up to 64 KB. Anything else becomes a failed entry, and the decision still answers:
```json
{ "key": "shipping_agent", "type": "agent", "status": "failed", "error": "The agent answered 503. upstream overloaded" }
```
Failures include a non-`2xx` answer (with the start of its body), a timeout, a network error, an address that isn't public and a missing secret.
The caller waits for `sync` agents, in parallel with [LLM answers](https://docs.dcision.io/docs/destinations/llm#latency); at most 3 of them run per execution.
## Hand-offs (`async`) [#hand-offs-async]
With `"mode": "async"`, the same signed request is queued and delivered with the [retry schedule](https://docs.dcision.io/docs/destinations/delivery) of webhooks — six attempts over about seven hours, 10 seconds each. Only the status of your answer matters: answer `2xx` once you've accepted the task, and continue the work on your side.
## A minimal agent endpoint [#a-minimal-agent-endpoint]
```ts
import express from "express";
import { DcisionError, verifySignature } from "@dcision/sdk";
const app = express();
// The raw body: the signature covers the exact bytes Dcision sent.
app.post("/support", express.raw({ type: "application/json" }), async (req, res) => {
try {
await verifySignature(req.body, req.header("dcision-signature"), process.env.DCISION_WEBHOOK_SECRET!);
} catch (error) {
return res.sendStatus(error instanceof DcisionError ? 400 : 500);
}
const task = JSON.parse(req.body.toString("utf8"));
const reply = await supportAgent.run({
input: task.input,
instructions: task.instructions,
tools: task.tools,
conversationId: task.decision.execution_id,
});
res.json({ reply });
});
```
```python
import json
import os
from fastapi import FastAPI, HTTPException, Request
from dcision import DcisionError, verify_signature
app = FastAPI()
@app.post("/support")
async def support(request: Request) -> dict:
body = await request.body() # the raw bytes: the signature covers them
try:
verify_signature(body, request.headers.get("dcision-signature"), os.environ["DCISION_WEBHOOK_SECRET"])
except DcisionError as error:
raise HTTPException(status_code=400, detail=error.message)
task = json.loads(body)
reply = await support_agent.run(task["input"], instructions=task["instructions"], tools=task["tools"])
return {"reply": reply}
```
The examples use the [SDKs](https://docs.dcision.io/docs/sdks), which are built from source until they are published.
## Data [#data]
An agent receives the **whole state** as `input`, plus the params you map. In the Playground, previews show the exact request — with secrets masked — without calling your agent.
---
# Workflows
> Trigger n8n, Make, Zapier, Pipedream or any workflow URL with the route's variables as flat JSON fields, plus the decision under dcision — signed and retried in the background.
Source: https://docs.dcision.io/docs/destinations/workflows
A **workflow** destination starts an automation you built in a no-code or low-code tool. Dcision POSTs the route's variables to the workflow's trigger URL — as flat JSON fields, the shape these tools map with a click — and delivers it in the background, with retries.
## Configure it [#configure-it]
```json
{
"key": "sales_crm",
"type": "workflow",
"when": { "conditions": [{ "field": "route", "on": "output", "operator": "eq", "value": "sales" }] },
"params": {
"message": { "from": "state.message", "required": true },
"company_size": { "from": "state.company_size" },
"lead_score": { "from": "composites.lead_score" },
"team": { "value": "inbound" }
},
"workflow": { "platform": "n8n", "url": "{{secrets.N8N_LEADS_WEBHOOK}}", "name": "New sales lead" }
}
```
| Field | Type | Default | Description |
| ---------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `platform` | string | `custom` | `n8n`, `make`, `zapier`, `pipedream` or `custom`. It labels the destination and suggests the URL format in the editor; the request is the same for all. |
| `url` | string | — | The workflow's trigger URL: `https://…`, or `{{secrets.NAME}}` holding the whole URL. Up to 2,048 characters, with variables. |
| `name` | string | — | Optional, up to 120 characters. Sent as `dcision.workflow`. |
In the editor, the params of a workflow are called **Workflow variables**.
| Platform | Trigger to create |
| --------- | ---------------------------------------------------------- |
| n8n | A **Webhook** node, method `POST`; use its production URL. |
| Make | A **Custom webhook** module. |
| Zapier | A **Webhooks by Zapier → Catch Hook** trigger. |
| Pipedream | An **HTTP / Webhook** trigger. |
| Custom | Any `https` endpoint that accepts a JSON `POST`. |
Trigger URLs are secrets in practice — anyone who has one can start your workflow. Store it as a [workspace secret](https://docs.dcision.io/docs/destinations/secrets) and use `{{secrets.NAME}}` as the URL, as above.
## The request [#the-request]
```json title="POST body"
{
"message": "We need pricing for 500 users and want to start next month.",
"company_size": 500,
"lead_score": 0.8414,
"team": "inbound",
"dcision": {
"decision": "lead-qualification",
"decision_id": "dec_3fKq9ZtW1mXcV7bN2pLa",
"version": 4,
"execution_id": "exec_8HsT2kQw9ZyR4vMn1cXe",
"result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
"confidence": { "purchase_intent": 0.8824, "priority": 0.69, "route": 0.85 },
"action": "continue",
"action_reason": { "type": "default" },
"destination": "sales_crm",
"workflow": "New sales lead",
"delivery_id": "dlv_8kJx2mQp4LzN7vR1tY6w",
"livemode": true
}
}
```
* Each param is a **top-level field**; the decision itself is under **`dcision`**. A param named `dcision` would be replaced by that object — pick another name.
* `dcision.workflow` is the destination's `name` (`null` without one), `dcision.version` is `null` in the Playground, and `dcision.livemode` is `false` for test keys and the Playground.
* The state is never sent as such — only the params you map.
The request carries `Content-Type: application/json`, the [Dcision headers](https://docs.dcision.io/docs/destinations/webhooks#headers) with its `Dcision-Signature`, and `Idempotency-Key` set to the delivery ID. Most no-code triggers can't verify an HMAC signature — keep the URL secret instead. When your workflow can run code, verify the signature like a [webhook](https://docs.dcision.io/docs/destinations/webhooks#verify-without-the-sdk).
## Delivery [#delivery]
Workflows are delivered **at least once**, with [six attempts over about seven hours](https://docs.dcision.io/docs/destinations/delivery): answer `2xx` quickly and let the workflow run asynchronously. A run started twice for the same delivery carries the same `dcision.delivery_id` — deduplicate on it when a duplicate would hurt, for example before creating a CRM record.
---
# Webhooks
> Receive the signed decision.completed event at your URL — the envelope, its headers, verifying Dcision-Signature with and without the SDKs, raw bodies in Express and Next.js, rotation and deduplication.
Source: https://docs.dcision.io/docs/destinations/webhooks
A **webhook** destination POSTs the decision's outcome to your URL as a signed `decision.completed` event, in the background and with retries. Use it to notify your backend: update a record, queue a job, start your own routing.
## Configure it [#configure-it]
```json
{
"key": "notify_sales",
"type": "webhook",
"when": { "conditions": [{ "field": "route", "on": "output", "operator": "eq", "value": "sales" }] },
"params": {
"message": { "from": "state.message" },
"team": { "value": "inbound" }
},
"webhook": { "url": "https://example.com/hooks/dcision" }
}
```
`webhook.url` is an `https://` URL of up to 2,048 characters — variables are URL-encoded — or `{{secrets.NAME}}` holding the whole URL. The body is always the event below: to send a body of your own — a Slack message, a CRM payload — use an [API request](https://docs.dcision.io/docs/destinations/http-requests).
## The event [#the-event]
```json title="POST body"
{
"id": "dlv_8kJx2mQp4LzN7vR1tY6w",
"type": "decision.completed",
"created": 1759658400,
"livemode": true,
"destination": "notify_sales",
"data": {
"decision": "lead-qualification",
"decision_id": "dec_3fKq9ZtW1mXcV7bN2pLa",
"version": 4,
"execution_id": "exec_8HsT2kQw9ZyR4vMn1cXe",
"result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
"confidence": { "purchase_intent": 0.8824, "priority": 0.69, "route": 0.85 },
"scores": { "priority": 2.81 },
"composites": { "lead_score": 0.8414 },
"action": "continue",
"action_reason": { "type": "default" },
"params": { "message": "We need pricing for 500 users and want to start next month.", "team": "inbound" }
}
}
```
* `id` is the **delivery ID**: the same on every attempt of this delivery. Deduplicate on it.
* `created` is when the decision ran, in Unix seconds; `livemode` is `false` for runs with a test key (`dcs_test_…`) and from the Playground.
* `data` carries the answers, the action and its reason, and `params` — **never the state itself**: map the fields the receiver needs as [params](https://docs.dcision.io/docs/destinations/params-and-templates).
Every field is described in [Webhook events](https://docs.dcision.io/docs/api/webhooks), the API reference of this event.
## Headers [#headers]
| Header | Value |
| ------------------- | ------------------------------------------------------------------ |
| `User-Agent` | `Dcision-Destinations/1.0 (+https://docs.dcision.io/destinations)` |
| `Content-Type` | `application/json` |
| `Dcision-Delivery` | The delivery ID, `dlv_…` — the same on every attempt. |
| `Dcision-Attempt` | The attempt number, `1` to `6`. |
| `Dcision-Event` | `decision.completed` |
| `Dcision-Signature` | `t=,v1=` — see below. |
## Verify the signature [#verify-the-signature]
Every delivery is signed with your workspace's **signing secret** — `whsec_` followed by 32 letters and digits. Admins and the Owner reveal it in **Settings → Destinations → Signing secret**; store it on your server as, for example, `DCISION_WEBHOOK_SECRET`.
```text
Dcision-Signature: t=1759658400,v1=662b905d30dbc7ccce5864c0e9dda66451117e8d3361c3e39bb9f91417199b52
signed_payload = "." +
v1 = hex( HMAC-SHA256( signing secret, signed_payload ) )
```
To accept a request:
1. Read the **raw body** — the exact bytes received. A body parsed and serialized again has different bytes and never verifies.
2. Split the header on `,` into `t=` and one or more `v1=` entries.
3. Compute the HMAC-SHA256 of `.` with the signing secret, as hex, and compare it with each `v1` **in constant time**. Accept when any of them matches.
4. Reject timestamps more than **300 seconds** away from your clock — the protection against replayed requests. `t` is the time of the attempt: every retry is signed again.
Answer `400` when verification fails. The SDKs do all of this in one call.
### Express [#express]
```ts
import express from "express";
import { DcisionError, verifyWebhook } from "@dcision/sdk";
const app = express();
// express.raw, not express.json: the signature covers the exact bytes Dcision sent.
app.post("/hooks/dcision", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await verifyWebhook(req.body, req.header("dcision-signature"), process.env.DCISION_WEBHOOK_SECRET!);
} catch (error) {
// A bad signature is final (400). Anything else is on our side: 500, so Dcision retries.
return res.sendStatus(error instanceof DcisionError ? 400 : 500);
}
if (await deliveries.seen(event.id)) return res.sendStatus(200);
await jobs.enqueue("dcision-decision", event.data);
res.sendStatus(200);
});
```
### Next.js route handler [#nextjs-route-handler]
```ts title="app/api/hooks/dcision/route.ts"
import { DcisionError, verifyWebhook, type WebhookEvent } from "@dcision/sdk";
export async function POST(request: Request) {
const body = await request.text(); // the raw body — not request.json()
let event: WebhookEvent;
try {
event = await verifyWebhook(body, request.headers.get("dcision-signature"), process.env.DCISION_WEBHOOK_SECRET!);
} catch (error) {
return new Response(null, { status: error instanceof DcisionError ? 400 : 500 });
}
await handleDecision(event.data); // throws → 500 → Dcision retries
return new Response(null, { status: 204 });
}
```
It runs on the Node.js and the Edge runtimes.
### Python [#python]
```python
import os
from flask import Flask, request
from dcision import DcisionError, verify_webhook
app = Flask(__name__)
@app.post("/hooks/dcision")
def dcision_webhook():
try:
# get_data(): the raw bytes. Don't read request.json first.
event = verify_webhook(request.get_data(), request.headers.get("Dcision-Signature"), os.environ["DCISION_WEBHOOK_SECRET"])
except DcisionError as error:
return error.message, 400
if deliveries.seen(event["id"]):
return "", 200
handle_decision(event["data"])
return "", 200
```
```python
import os
from fastapi import FastAPI, HTTPException, Request, Response
from dcision import DcisionError, verify_webhook
app = FastAPI()
@app.post("/hooks/dcision")
async def dcision_webhook(request: Request) -> Response:
body = await request.body() # raw bytes: don't declare a Pydantic body for this route
try:
event = verify_webhook(body, request.headers.get("dcision-signature"), os.environ["DCISION_WEBHOOK_SECRET"])
except DcisionError as error:
raise HTTPException(status_code=400, detail=error.message)
await handle_decision(event["data"]) # raises → 500 → Dcision retries
return Response(status_code=204)
```
The SDKs raise `INVALID_SIGNATURE` for a missing or malformed header, a signature that doesn't match, a timestamp outside the tolerance or a body that isn't an event, and a `TypeError` for misuse — no secret, or a body that was already parsed. See [TypeScript SDK](https://docs.dcision.io/docs/sdks/typescript#webhooks) and [Python SDK](https://docs.dcision.io/docs/sdks/python#webhooks). The SDKs are built from source until they are published.
### Verify without the SDK [#verify-without-the-sdk]
```js
import { createHmac, timingSafeEqual } from "node:crypto";
/** Verifies Dcision-Signature over the raw request body (a string or a Buffer). */
export function verifyDcisionSignature(rawBody, header, secret, toleranceSec = 300) {
let timestamp;
const signatures = [];
for (const part of (header ?? "").split(",")) {
const [name, value = ""] = part.trim().split("=");
if (name === "t") timestamp = value;
else if (name === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(Buffer.from(value, "hex"));
}
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
if (!signatures.some((signature) => timingSafeEqual(signature, expected))) return false;
return Math.abs(Date.now() / 1000 - Number(timestamp)) <= toleranceSec;
}
```
```python
import hashlib
import hmac
import time
def verify_dcision_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
"""Verifies Dcision-Signature over the raw request body."""
timestamp, signatures = None, []
for part in (header or "").split(","):
name, _, value = part.strip().partition("=")
if name == "t":
timestamp = value
elif name == "v1":
signatures.append(value.lower())
if not timestamp or not timestamp.isdigit() or not signatures:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, signature) for signature in signatures):
return False
return abs(time.time() - int(timestamp)) <= tolerance
```
### Test vector [#test-vector]
Check your implementation against this signature — the one the SDKs are tested with. The body is exactly one line, UTF-8, with no trailing newline:
```text title="Inputs"
secret whsec_9vX2kLmQ4pRt7wYz1aBc3dEf5gHj8KnS
t 1759658400
```
```json title="Raw body"
{"id":"dlv_8kJx2mQp4LzN7vR1tY6w","type":"decision.completed","created":1759658400,"livemode":true,"destination":"notify_sales","data":{"decision":"lead-qualification","decision_id":"dec_4Qm9Tz2Lx7Wv1Rb8Nc3K","version":3,"execution_id":"exec_8HsT2kQw9ZyR4vMn1cXe","result":{"route":"sales"},"confidence":{"route":0.91},"scores":{},"composites":{},"action":"continue","action_reason":{"type":"default"},"params":{"message":"Olá! Preciso de preço para 500 licenças — urgente 🚀","team":"inbound"}}}
```
```text title="Expected"
v1 662b905d30dbc7ccce5864c0e9dda66451117e8d3361c3e39bb9f91417199b52
```
Use a clock of `1759658400` — or a large tolerance — when you test the timestamp check.
## Rotate the signing secret [#rotate-the-signing-secret]
**Rotate** in **Settings → Destinations** creates a new secret at once. For the next **24 hours** Dcision signs every delivery with **both** secrets, so the header carries two `v1=` entries — `t=…,v1=,v1=` — and a receiver that knows either one keeps verifying:
1. Rotate, and copy the new secret.
2. Deploy your receivers with both secrets — the SDKs take a list: `verifyWebhook(body, header, [newSecret, oldSecret])`.
3. Within 24 hours, remove the old secret.
## Respond and deduplicate [#respond-and-deduplicate]
* **Answer `2xx` within 10 seconds**, then do the work asynchronously. Slow answers count as timeouts and are retried.
* **Deliveries are at least once**: a timeout or a lost answer means the event can arrive again. Deduplicate on `id` (or `Dcision-Delivery`).
* **Every non-`2xx` answer counts**: `408`, `425`, `429` and `5xx` are retried — six attempts over about seven hours; other `4xx` answers and redirects are final. See [Delivery and retries](https://docs.dcision.io/docs/destinations/delivery).
* **Check `livemode`** to keep test and Playground events out of production systems.
---
# HTTP requests
> Call any API after a decision — method, URL, headers and a JSON body built from variables and workspace secrets — delivered with retries, an Idempotency-Key and a signature.
Source: https://docs.dcision.io/docs/destinations/http-requests
An **API request** destination (type `http`) calls any HTTP API with a request you design — create a lead in your CRM, open a ticket, post to Slack — in the background, with retries. Tokens stay in [workspace secrets](https://docs.dcision.io/docs/destinations/secrets), never in the decision.
## Configure it [#configure-it]
```json
{
"key": "create_lead",
"type": "http",
"when": { "conditions": [{ "field": "route", "on": "output", "operator": "eq", "value": "sales" }] },
"params": {
"email": { "from": "state.email", "required": true },
"score": { "from": "composites.lead_score" }
},
"http": {
"method": "POST",
"url": "https://api.crm.example.com/v2/leads?source={{decision.slug}}",
"headers": [{ "name": "Authorization", "value": "Bearer {{secrets.CRM_TOKEN}}" }],
"body": "{\"email\": {{params.email}}, \"score\": {{params.score}}, \"note\": \"Routed to {{result.route}} by Dcision\"}"
}
}
```
| Field | Type | Default | Description |
| --------- | ------ | ------------------ | ------------------------------------------------------------------------------------------------------- |
| `method` | string | `POST` | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. |
| `url` | string | — | `https://…` or `{{secrets.NAME}}` holding the whole URL; up to 2,048 characters, variables URL-encoded. |
| `headers` | array | `[]` | Up to 20 `{ "name", "value" }` pairs; values up to 2,048 characters, with variables and secrets. |
| `body` | string | the params as JSON | `POST`, `PUT` and `PATCH` only: a JSON template of up to 10,000 characters. |
## The body [#the-body]
The body is a **JSON template**: outside quotes a variable becomes a JSON value, inside quotes it becomes text — see [JSON bodies](https://docs.dcision.io/docs/destinations/params-and-templates#json-bodies). With the destination above, the CRM receives:
```json
{ "email": "ana@acme.com", "score": 0.8414, "note": "Routed to sales by Dcision" }
```
* **No body template** on `POST`, `PUT` or `PATCH`: the params are sent as a JSON object.
* `Content-Type: application/json` is set unless you add your own `Content-Type` header.
* **`GET` and `DELETE` have no body** — a body on them is a schema error. Their params are added to the query string (`?email=ana%40acme.com&score=0.8414`), unless the URL already uses `{{params.…}}`.
## Headers [#headers]
Your headers are sent with Dcision's:
| Header | Value |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Your headers | Their values rendered as text — line breaks become spaces. |
| `Content-Type` | `application/json` when there is a body, unless you set it. |
| `User-Agent` | `Dcision-Destinations/1.0 (+https://docs.dcision.io/destinations)`, unless you set it. |
| `Idempotency-Key` | The delivery ID, `dlv_…`, unless you set it — the same on every attempt, so APIs that support idempotency keys never apply a retry twice. |
| `Dcision-Delivery`, `Dcision-Attempt`, `Dcision-Event`, `Dcision-Signature` | As for [webhooks](https://docs.dcision.io/docs/destinations/webhooks#headers). |
Some names are set by Dcision or the HTTP layer and can't be used: `Host`, `Content-Length`, `Connection`, `Transfer-Encoding`, `Upgrade`, `Keep-Alive`, `TE`, `Trailer`, `Expect`, and any name that starts with `Proxy-` or `Dcision-`.
## Authentication [#authentication]
Put credentials in a header with a secret — `Authorization: Bearer {{secrets.CRM_TOKEN}}` — or in the URL as a secret when the API expects a secret URL (`{{secrets.SLACK_WEBHOOK}}`). Credentials in the URL itself (`https://user:password@…`) are refused.
## The signature [#the-signature]
API requests are signed like webhooks, over the exact body sent — an empty body for `GET` and `DELETE`. If the receiving API is yours, verify `Dcision-Signature` with `verifySignature` (TypeScript) or `verify_signature` (Python): the body is your template, not a webhook event. See [Verify the signature](https://docs.dcision.io/docs/destinations/webhooks#verify-the-signature).
## Example: a Slack message [#example-a-slack-message]
Slack's incoming webhooks expect their own JSON, so use an API request with the webhook URL stored as a secret:
```json
{
"key": "escalation_alert",
"type": "http",
"when": { "actions": ["escalate"] },
"params": { "message": { "from": "state.message" } },
"http": {
"method": "POST",
"url": "{{secrets.SLACK_WEBHOOK}}",
"body": "{\"text\": \"{{decision.slug}} asks for a person ({{action}}): {{params.message}}\"}"
}
}
```
The message is written inside quotes, so the param is inserted as escaped text — quotes and line breaks in the lead's message can't break the JSON.
---
# Functions
> Let your own code act on a result — the response names the function and its params, and the TypeScript and Python SDKs call the handler you registered, in order and once per call.
Source: https://docs.dcision.io/docs/destinations/functions
A **function** destination runs nothing on Dcision's side. The response names a function and the params to call it with, and your code — usually through the [SDKs](https://docs.dcision.io/docs/sdks) — calls the matching handler. Use it when the next step lives in the process that called the decision: assign an owner, write to your database, call an internal service.
## Configure it [#configure-it]
```json
{
"key": "assign_owner",
"type": "function",
"when": { "conditions": [{ "field": "route", "on": "output", "operator": "eq", "value": "sales" }] },
"params": {
"email": { "from": "state.email", "required": true },
"route": { "from": "result.route" }
},
"function": { "name": "assignToSales" }
}
```
`function.name` is a valid identifier: letters, digits, `_` and `$`, up to 64 characters, not starting with a digit. The editor prints the handler you need to register.
## In the response [#in-the-response]
```json
{ "key": "assign_owner", "type": "function", "function": "assignToSales", "params": { "email": "ana@acme.com", "route": "sales" } }
```
A function entry has no `status` when it fired. When a required param is missing or the [limit](https://docs.dcision.io/docs/destinations/triggers#evaluation) is reached, the entry is `"status": "skipped"` instead — and must not be called.
## With the SDKs [#with-the-sdks]
Pass your handlers to `decide`: the SDK calls them after the decision, with the params and the decision itself.
```ts
import { Dcision } from "@dcision/sdk";
const dcision = new Dcision(); // reads DCISION_API_KEY
const decision = await dcision.decide("lead-qualification", { message, email, company_size: 500 }, {
idempotencyKey: `lead-${lead.id}`,
functions: {
assignToSales: async (params, decision) => crm.assign(String(params.email), decision.execution_id),
},
});
decision.functionResults; // [{ key: "assign_owner", function: "assignToSales", result: … }]
```
```python
from dcision import Dcision
client = Dcision() # reads DCISION_API_KEY
def assign_to_sales(params, decision):
return crm.assign(params["email"], decision.execution_id)
decision = client.decide(
"lead-qualification",
{"message": message, "email": email, "company_size": 500},
idempotency_key=f"lead-{lead['id']}",
functions={"assignToSales": assign_to_sales},
)
decision.function_results # [FunctionResult(key="assign_owner", function="assignToSales", result=…)]
```
How the SDKs dispatch:
* Handlers run **in the order of `destinations`, one at a time**: an async handler is awaited before the next starts. Only function entries without a `status` are called.
* **Every handler is looked up first.** If one is missing, the call fails with `FUNCTION_NOT_REGISTERED` and no handler runs — or pass `onMissingFunction: "ignore"` (`on_missing_function="ignore"` in Python) to skip it.
* **A handler that throws** stops the dispatch: the call fails with `FUNCTION_FAILED`, carrying the error as `cause`, the `decision` and the results of the handlers that completed. The decision already ran — and was billed.
* The Python client is synchronous: an `async def` handler fails with `FUNCTION_FAILED`.
## Dispatch later [#dispatch-later]
Dispatch somewhere else — a worker, a queue consumer — from the stored response:
```ts
import { Decision } from "@dcision/sdk";
const decision = await dcision.decide("lead-qualification", state); // no functions: nothing is called
await queue.push(JSON.stringify(decision));
// in the worker
const results = await new Decision(JSON.parse(job.payload)).dispatch(handlers, { onMissingFunction: "ignore" });
```
```python
import json
from dcision import Decision
decision = client.decide("lead-qualification", state) # no functions: nothing is called
queue.push(json.dumps(decision.raw))
# in the worker
results = Decision(json.loads(job.payload)).dispatch(handlers, on_missing_function="ignore")
```
## Make handlers idempotent [#make-handlers-idempotent]
The SDKs dispatch once per `decide` call, after the final answer — their internal retries never dispatch twice. But calling `decide` again with the same `Idempotency-Key` returns the stored answer, and its functions are dispatched **again**. Key the work on `decision.execution_id`, or on your own record ID, so a second call is harmless.
## Without an SDK [#without-an-sdk]
Loop over the entries yourself:
```js
for (const destination of decision.destinations ?? []) {
if (destination.type !== "function" || destination.status) continue; // skipped entries carry a status
await handlers[destination.function](destination.params, decision);
}
```
---
# Workspace secrets
> Keep tokens and secret URLs out of decisions — workspace secrets referenced as {{secrets.NAME}}, who can manage them, how they are stored and masked, and when changes apply.
Source: https://docs.dcision.io/docs/destinations/secrets
API tokens, passwords and secret URLs — a Slack or n8n webhook URL — don't belong in a decision: the schema is versioned, shown in the editor and stored with every deploy. Keep them in **workspace secrets** and reference them by name.
## Add a secret [#add-a-secret]
In **Settings → Destinations → Secrets**, enter a **name** and a **value** and click **Add secret** (or **Replace** for an existing name).
* **Names** are UPPER_SNAKE_CASE: a capital letter, then capital letters, digits and `_`, up to 64 characters — `CRM_TOKEN`, `SLACK_WEBHOOK`.
* **Values** are 1 to 4,096 characters and **write-only**: once saved, the app shows the name, the last 4 characters (to Admins) and when it changed — never the value.
* A workspace keeps up to **50** secrets.
## Use it [#use-it]
Write `{{secrets.NAME}}` where a destination needs it:
| Where | Example |
| -------------- | --------------------------------------------------------------------- |
| A header value | `Authorization: Bearer {{secrets.CRM_TOKEN}}` |
| A whole URL | `{{secrets.SLACK_WEBHOOK}}` as the URL of an API request or a webhook |
| An `http` body | `{"api_key": {{secrets.MAILER_KEY}}}` |
A secret can be the start of a URL — the whole URL — or part of a header or a body. Secrets **can't** be used in fixed replies, LLM prompts and inputs, or agent instructions: that text is returned to the caller or sent to a model.
## Who can do what [#who-can-do-what]
| | Viewer | Member | Admin | Owner |
| ---------------------------------------------------------------------------------------- | ------ | ------ | ----- | ----- |
| See secret names (to write templates) | — | ✓ | ✓ | ✓ |
| See the last 4 characters | — | — | ✓ | ✓ |
| Add, replace and delete secrets | — | — | ✓ | ✓ |
| Reveal and rotate the [signing secret](https://docs.dcision.io/docs/destinations/webhooks#verify-the-signature) | — | — | ✓ | ✓ |
## How they are protected [#how-they-are-protected]
* Encrypted at rest with **AES-256-GCM**. Values never leave the API except inside the request they are rendered into.
* **Never in the decision schema**, the API response, the execution log or the Playground: previews show `••••` in their place, and the delivery log keeps only a masked preview of each request — method, URL, headers and the body's size.
* The full request, with secrets, is stored **encrypted** while a delivery may still need it — see [Delivery and retries](https://docs.dcision.io/docs/destinations/delivery#resend-a-failed-delivery).
## When changes apply [#when-changes-apply]
Secrets are read **when the decision runs**: Dcision renders the request with the current values and stores it, encrypted, for its attempts.
* A new or changed value applies to the **next runs**. Deliveries already queued — and their retries — keep the request they were rendered with.
* A destination that references a secret that doesn't exist fails its delivery at once, with *"Set the secret CRM_TOKEN in Settings → Destinations, then resend."* — nothing is sent. Because the request was rendered without the secret, add it and **run the decision again**: resending that delivery fails the same way. An agent in `sync` mode returns `"status": "failed"` with the same advice.
* Deleting a secret makes the destinations that use it fail until it is set again.
## The signing secret [#the-signing-secret]
Separate from these secrets, every workspace has one **signing secret** (`whsec_…`) that signs all its deliveries. Admins and the Owner reveal and rotate it in **Settings → Destinations → Signing secret** — see [Verify the signature](https://docs.dcision.io/docs/destinations/webhooks#verify-the-signature) and [Rotate the signing secret](https://docs.dcision.io/docs/destinations/webhooks#rotate-the-signing-secret).
---
# Delivery and retries
> How Dcision delivers webhooks, API requests, workflows and agent hand-offs — at least once, six attempts over about seven hours, Retry-After, timeouts, statuses, resends and retention.
Source: https://docs.dcision.io/docs/destinations/delivery
Webhooks, API requests, workflows and agents in `async` mode are **deliveries**: the decision answers first, and Dcision sends them in the background. The response tells you what was queued:
```json
{ "key": "notify_sales", "type": "webhook", "status": "queued", "delivery_id": "dlv_8kJx2mQp4LzN7vR1tY6w" }
```
## At least once [#at-least-once]
A delivery is sent **at least once**. A receiver that times out or loses its answer gets the same delivery again, so make receivers **idempotent** with the delivery ID — the same on every attempt and on resends:
| Type | Where the delivery ID is |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| All | The `Dcision-Delivery` header. |
| `webhook` | The event's `id`. |
| `http`, `workflow`, `agent` | The `Idempotency-Key` header (unless you set your own); `dcision.delivery_id` in workflows, `decision.delivery_id` for agents. |
Deliveries aren't ordered: two deliveries of the same decision — or of two decisions — can arrive in any order.
## Attempts [#attempts]
| Attempt | When |
| ------- | ---------------------------------- |
| 1 | immediately |
| 2 | 30 seconds after the first attempt |
| 3 | 2 minutes after the second |
| 4 | 10 minutes after the third |
| 5 | 1 hour after the fourth |
| 6 | 6 hours after the fifth |
That is **6 attempts** over about **7 hours**. The `Dcision-Attempt` header says which one a request is, and every attempt is signed again with a fresh timestamp.
| The receiver… | Outcome |
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| answers `2xx` | **Delivered.** |
| answers `408`, `425`, `429` or `5xx`, times out or can't be reached | Retried on the schedule above. With `429` or `503`, a `Retry-After` header — in seconds or as a date — sets the wait instead, from 1 second up to 6 hours. |
| answers any other `4xx` | **Failed** at once: fix the destination, then resend. |
| answers a redirect (`3xx`) | **Failed** at once: redirects are never followed — use the final URL. |
| resolves to an address that isn't public, or the URL is invalid once variables are filled in | **Failed** at once — see [Security and limits](https://docs.dcision.io/docs/destinations/security-and-limits). |
Each attempt has a **10-second** deadline. Dcision reads up to 64 KB of the answer and keeps its status code, the latency and its first 1,024 characters, which the app shows next to the delivery.
## Statuses [#statuses]
| Status | In the app | Meaning |
| ----------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
| `PENDING` | *sending* · *retrying* | Waiting for its next attempt. |
| `SENDING` | *sending* | An attempt is in flight. An attempt interrupted by a restart is picked up again after 60 seconds. |
| `SUCCEEDED` | *delivered* | A `2xx` answer. |
| `FAILED` | *failed* | A final failure, or 6 attempts without success. |
Where to follow them:
* The **Playground** result and an execution's details in **Executions** show each destination with its live status, the attempts, the HTTP status and latency, the next attempt and the error — and *test mode* for `livemode: false` deliveries.
* The decision's [Overview](https://docs.dcision.io/docs/guides/overview-and-calibration#destinations) counts delivered, failed and pending deliveries per destination.
Everyone in the workspace can see deliveries; the app never shows their body — only a masked preview of the request.
## Resend a failed delivery [#resend-a-failed-delivery]
**Resend** on a failed delivery (Members, Admins and the Owner) queues it again with **6 new attempts** and the **same delivery ID**, so a receiver that did process it can still deduplicate.
The rendered request — with its secrets — is stored encrypted while a delivery may need it: it is deleted when the delivery succeeds and kept for failed ones, so they can be resent. A resend sends that same request: a change to the destination or to a secret applies to new runs of the decision, not to resends.
## Pace and limits [#pace-and-limits]
* A workspace sends up to **600 deliveries per minute**. Deliveries above that wait for the next minute without using an attempt.
* The response doesn't wait for deliveries: they are queued with the run and start at once, in the background.
* If a delivery can't be queued at all (the queue is unavailable), its entry says `"status": "skipped", "reason": "unavailable"`, and the decision still answers.
## Retention [#retention]
Finished deliveries — succeeded or failed — are deleted **30 days** after they were created, whatever the workspace's execution log retention. Deleting a decision doesn't cancel deliveries already queued: they finish their attempts and expire with the others. Deleting the workspace deletes them all.
## Idempotency and replays [#idempotency-and-replays]
* An [idempotent replay](https://docs.dcision.io/docs/api/idempotency) returns the stored response with the same delivery IDs, and nothing is delivered again.
* A request that is still running with the same `Idempotency-Key` gets `409` with `Retry-After: 1` — it never runs, or delivers, twice.
* An [engine-error fallback](https://docs.dcision.io/docs/destinations/triggers#when-the-engine-fails) isn't stored for idempotency: a retry runs the decision again and queues new deliveries, with new IDs.
---
# Testing destinations
> Preview destinations in the Playground with secrets masked, run them for real with livemode false, use test API keys and follow deliveries, attempts and receiver answers.
Source: https://docs.dcision.io/docs/destinations/testing
## Previews in the Playground [#previews-in-the-playground]
The [Playground](https://docs.dcision.io/docs/concepts/playground) runs the draft, and by default it only **previews** destinations — nothing is called or sent:
| Type | The preview shows |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Fixed reply | The reply, rendered as a chat bubble with its buttons. |
| LLM | The prompt that would be sent: provider, model, the rendered prompt and message. The model isn't called. |
| Webhook, API request, workflow, agent | The exact request — method, URL, headers and body — with every secret shown as `••••`. |
| Function | The call your code would make, such as `assignToSales({ "email": "ana@acme.com" })`. |
```json title="Preview entries"
[
{
"key": "sales_llm",
"type": "llm",
"status": "preview",
"prompt": {
"provider": "openrouter",
"model": "openai/gpt-5-mini",
"instructions": "You are Acme's sales team. Answer the lead in two sentences, say that a person will follow up today and never quote prices.",
"input": "We need pricing for 500 users and want to start next month."
}
},
{
"key": "create_lead",
"type": "http",
"status": "preview",
"request": {
"method": "POST",
"url": "https://api.crm.example.com/v2/leads?source=lead-qualification",
"headers": { "authorization": "Bearer ••••", "content-type": "application/json" },
"body": "{\"email\": \"ana@acme.com\", \"score\": 0.8414, \"note\": \"Routed to sales by Dcision\"}"
}
}
]
```
Previews use the same rendering as real runs, so they are the place to check URLs, encodings and JSON bodies before anything leaves Dcision. Dcision's own headers — the signature, the delivery ID — are added when a request is really sent.
## Run destinations for real [#run-destinations-for-real]
When the decision has destinations, the Playground shows **Run destinations for real** next to **Run decision**. Switch it on and the next runs:
* call LLM models and agents in `sync` mode, and show their answers;
* send webhooks, API requests, workflows and `async` agent hand-offs, with the usual [retries](https://docs.dcision.io/docs/destinations/delivery);
* mark everything as **test**: `livemode` is `false` in webhook events, in a workflow's `dcision` object and in an agent's `decision` object, and the app labels the deliveries *test mode*.
Playground runs aren't billed — an LLM provider still bills its tokens to your key — and they count toward the Playground's limits of 30 runs per minute and 2,000 per day. In the Playground, `decision.version` is `null`: it runs the draft.
## Test API keys [#test-api-keys]
Calls made with a **test key** (`dcs_test_…`) run the deployed version and deliver for real, with `livemode: false`. Use them for staging and CI against staging receivers. They are billed like live calls — see [Live and test keys](https://docs.dcision.io/docs/api/authentication#live-and-test-keys).
Receivers should check the flag and keep test events out of production systems. API requests carry no `livemode` field — point them at a staging URL while you test.
## Follow what happened [#follow-what-happened]
* The Playground's **Destinations** card updates each delivery while it runs: *sending*, *retrying*, *delivered* or *failed*, the attempts, the HTTP status and latency, the next attempt and the receiver's error. **Resend** retries a failed delivery.
* **Executions** shows the same card for every run, from the API too.
* The decision's [Overview](https://docs.dcision.io/docs/guides/overview-and-calibration#destinations) counts answers returned, deliveries made, failed and pending, per destination.
* The [CLI](https://docs.dcision.io/docs/cli)'s `dcision validate` lists a schema's destinations, and `dcision decide --json` prints the `destinations` of a response.
## Before you go live [#before-you-go-live]
**Preview** the destinations in the Playground with a few real states — one per route — and check every URL, header and body.
**Run them for real** against staging receivers: verify the signature, deduplicate on the delivery ID and ignore or route `livemode: false` events.
**Deploy**, call the endpoint with a test key, then switch your integration to a live key. Watch the decision's Overview for failed destinations.
---
# Destination security and limits
> How Dcision protects outgoing requests — https only, SSRF guard, no redirects, secrets — what each destination type sends and stores, who can configure what, and every destination limit.
Source: https://docs.dcision.io/docs/destinations/security-and-limits
## Outgoing requests [#outgoing-requests]
Webhooks, API requests, workflows and agents call URLs you configure, so Dcision guards every connection:
* **https only.** A URL starts with `https://`, or with a secret that holds the whole URL. `http://` URLs are refused when you save.
* **Public addresses only.** The host name is resolved when Dcision connects, and **every address is checked at that moment**, inside the connection itself — a name can't resolve to a public address for the check and a private one for the request. Literal IP addresses are checked too.
* **No credentials in URLs.** `https://user:password@…` is refused: put credentials in a header, with a secret.
* **No redirects.** A `3xx` answer is a failed delivery; Dcision never follows it.
* **Short and bounded.** 10 seconds per delivery attempt (up to 25 seconds for LLM and `sync` agent calls), answers read up to 64 KB.
Blocked addresses:
| Range | What it is |
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| `0.0.0.0/8` | "This" network |
| `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | Private networks |
| `100.64.0.0/10` | Carrier-grade NAT |
| `127.0.0.0/8` | Loopback |
| `169.254.0.0/16` | Link-local, including cloud metadata services |
| `192.0.0.0/24`, `192.0.2.0/24`, `192.88.99.0/24`, `198.18.0.0/15`, `198.51.100.0/24`, `203.0.113.0/24` | Reserved, test and benchmarking ranges |
| `224.0.0.0/4`, `240.0.0.0/4` | Multicast, reserved and broadcast |
| `::`, `::1`, `fc00::/7`, `fe80::/10`, `fec0::/10`, `ff00::/8`, `2001:db8::/32`, `100::/64` | IPv6 unspecified, loopback, unique local, link-local, site-local, multicast, documentation and discard |
| IPv4-mapped, IPv4-compatible, NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) addresses | Blocked when the IPv4 address inside is |
Host names that end in `.local`, `.internal` or `.localdomain` — and names that resolve to any blocked address, such as internal service names — are refused too.
## Secrets [#secrets]
* [Workspace secrets](https://docs.dcision.io/docs/destinations/secrets) are encrypted with AES-256-GCM and never appear in the decision schema, responses, execution logs or the Playground — previews mask them as `••••`.
* The values of your headers are never written to Dcision's logs; keep tokens in secrets anyway, because the app shows a masked preview of every delivery's request, headers included.
* The signing secret is encrypted too, and only Admins and the Owner can reveal or rotate it.
## What leaves Dcision [#what-leaves-dcision]
| Type | What the receiver gets |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `reply` | Nothing leaves: the text is returned to the caller. |
| `function` | Nothing leaves: the function name and params are returned to the caller. |
| `llm` | The model provider — under **your** key and account — gets the route's prompt and the input: the whole state unless you set `input`. |
| `agent` | Your agent gets the **whole state** as `input`, the instructions, tools, params and the decision's answers. |
| `webhook` | The answers, confidence, weighted levels, composites, action, reason and params — **never the state**. |
| `workflow` | The params and the decision's answers — **never the state**. |
| `http` | Exactly what your URL, headers and body template contain. |
Map only what a receiver needs: params are the [data-minimization](https://docs.dcision.io/docs/destinations/params-and-templates#where-params-go) tool of destinations.
## What Dcision stores [#what-dcision-stores]
* **The execution** keeps the run's `destinations` entries — including function params, fixed replies, LLM texts and agent answers — whatever the decision's `storeInput` and `storeOutput` settings. They follow the execution's log retention and appear in [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions). If params carry personal data, the entries do too.
* **Deliveries** keep their status, attempts, the answer's status code, latency and first 1,024 characters, a masked preview of the request — method, URL, headers and the body's size, never the body — and, encrypted, the full request while it may be resent. They are deleted 30 days after they were created.
See [Security and data](https://docs.dcision.io/docs/security) for the rest of what Dcision stores.
## Who can configure what [#who-can-configure-what]
| Action | Viewer | Member | Admin | Owner |
| --------------------------------------------- | ------ | ------ | ----- | ----- |
| See destinations, deliveries and their status | ✓ | ✓ | ✓ | ✓ |
| Add, edit and deploy destinations | — | ✓ | ✓ | ✓ |
| Run destinations for real in the Playground | — | ✓ | ✓ | ✓ |
| Resend a failed delivery | — | ✓ | ✓ | ✓ |
| See secret names | — | ✓ | ✓ | ✓ |
| Manage secrets and the signing secret | — | — | ✓ | ✓ |
| Save LLM provider keys (Settings → Engine) | — | — | ✓ | ✓ |
## Limits [#limits]
| Limit | Value |
| ------------------------------------------- | ---------------------------------- |
| Destinations per decision | 20 |
| Destinations that fire per execution | 10 |
| LLM answers and `sync` agents per execution | 3 |
| Conditions per destination | 5 |
| Params per destination | 30 |
| Fixed param value | up to 500 characters |
| Headers per destination | 20 |
| Header value | up to 2,048 characters |
| URL | up to 2,048 characters |
| `http` body template | up to 10,000 characters |
| Destination description | up to 2,000 characters |
| Reply text | up to 4,000 characters |
| Reply buttons | up to 10, up to 80 characters each |
| LLM prompt, LLM input, agent instructions | up to 8,000 characters each |
| Agent tools | up to 50 |
| LLM and `sync` agent timeout | 1,000 to 25,000 ms, default 20,000 |
| LLM `maxTokens` | 16 to 2,048, default 512 |
| LLM `temperature` | 0 to 2, default 0.3 |
| Agent answer returned in `response` | up to 16,000 characters |
| Delivery attempts | 6, over about 7 hours |
| Delivery attempt timeout | 10 seconds |
| Deliveries per workspace | 600 per minute |
| Delivery retention | 30 days |
| Workspace secrets | 50, values up to 4,096 characters |
---
# API overview
> Base URL, versioning, request and response conventions, headers and IDs of the Dcision public API.
Source: https://docs.dcision.io/docs/api
The public API runs deployed decisions on a state and returns typed answers, confidence and an action. Read endpoints, with the same API keys, list the workspace's decisions and their contracts, its account and its recent executions. A decision can also run from a secret [webhook URL](https://docs.dcision.io/docs/api/webhook-trigger), without an API key, and agents can use all of it through the [MCP server](https://docs.dcision.io/docs/mcp).
```text
Base URL https://api.dcision.io
Version /v1 (in the path)
Auth Authorization: Bearer dcs_live_… | dcs_test_…
Format JSON in, JSON out
```
## Endpoints [#endpoints]
| Method and path | Description |
| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| [`POST /v1/decisions/{slug}`](https://docs.dcision.io/docs/api/run-decision) | Run the active version of a decision. |
| [`GET /v1/decisions`](https://docs.dcision.io/docs/api/decisions#list-decisions) | The workspace's decisions: slug, name, status and active version. |
| [`GET /v1/decisions/{slug}`](https://docs.dcision.io/docs/api/decisions#get-a-decision) | What a decision's active version accepts and answers: state schema, questions, actions and an example state. |
| [`GET /v1/me`](https://docs.dcision.io/docs/api/me) | The key's workspace, the key, the plan and the current period's usage. |
| [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions) | Recent executions with answers, action and metrics — never the inputs. |
| [`POST /v1/hooks/{token}`](https://docs.dcision.io/docs/api/webhook-trigger) | Run a decision from its secret webhook URL — no API key; optionally signed with a `whsec_` secret. |
| [`POST /mcp`](https://docs.dcision.io/docs/mcp) | The [MCP server](https://docs.dcision.io/docs/mcp) for AI agents — the same keys, limits and billing. |
| `GET /openapi.json` | The OpenAPI 3.1 description of the public API, including the [`decision.completed` webhook event](https://docs.dcision.io/docs/api/webhooks). No authentication. |
| `GET /health` | Service health: `{ "status": "ok" }`. No authentication. |
Decisions, versions, destinations, API keys, members and billing are managed in the [app](https://app.dcision.io). The app's own endpoints require a user session and refuse API keys with `403 FORBIDDEN`. Webhooks travel the other way — from Dcision to you — see [Webhook events](https://docs.dcision.io/docs/api/webhooks).
## Requests [#requests]
* **HTTPS only**, to `https://api.dcision.io`.
* **JSON body** with `Content-Type: application/json`, UTF-8, at most **128 KB**.
* **Server-side only**: API keys are secrets — call the API from your backend, a worker or an agent runtime, never from a browser or a mobile app.
| Request header | Required | Description |
| ----------------- | -------- | -------------------------------------------------------------------------------------------------- |
| `Authorization` | Yes | `Bearer ` — see [Authentication](https://docs.dcision.io/docs/api/authentication). |
| `Content-Type` | Yes | `application/json`. |
| `Idempotency-Key` | No | Makes retries safe — see [Idempotency](https://docs.dcision.io/docs/api/idempotency). |
| `X-Request-ID` | No | Your correlation ID: 1–128 characters from `A–Z a–z 0–9 . _ : -`. Otherwise Dcision generates one. |
## Responses [#responses]
* JSON with **snake_case** fields; your question keys are used as-is.
* Standard HTTP status codes: `200` on success, `4xx` for problems with the request, `5xx` for problems on Dcision's or the engine's side.
* Errors always have the same shape — see [Errors](https://docs.dcision.io/docs/api/errors):
```json
{ "error": { "code": "INVALID_STATE", "message": "Field company_size must be a number.", "request_id": "req_7Gm2xPq9Lk4sVt1RbN8w" } }
```
| Response header | When | Description |
| ----------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `X-Request-ID` | Always | Your `X-Request-ID`, or a generated `req_…` ID. Quote it when you contact support. |
| `X-RateLimit-Limit` | Authenticated calls | Requests allowed per minute for your workspace — decisions and reads have separate windows. |
| `X-RateLimit-Remaining` | Authenticated calls | Requests left in the current window. |
| `X-RateLimit-Reset` | Authenticated calls | When the window resets, in Unix seconds. |
| `Retry-After` | `429`, and `409` while a request with the same `Idempotency-Key` is still running | Seconds to wait before retrying. |
| `Idempotent-Replayed` | Replays | `true` when the response is a stored replay. |
## IDs [#ids]
IDs are a prefix followed by 20 letters and digits:
| Prefix | Identifies | Where you see it |
| ------- | --------------------------- | --------------------------------------------------------------------------------- |
| `dec_` | a decision | `decision_id` in responses, the decision's page in the app |
| `exec_` | an execution (one run) | `execution_id` in responses, **Executions**, `GET /v1/executions` |
| `key_` | an API key | `api_key.id` in `GET /v1/me` |
| `dlv_` | a delivery of a destination | `delivery_id` in responses, the `Dcision-Delivery` header, a webhook event's `id` |
| `req_` | a request | `X-Request-ID`, `request_id` in errors |
## Stability [#stability]
The `/v1` request and response shapes are stable; new fields are only added — such as `scores`, `composites` and the token counts in `metrics` in v0.2, and `destinations` in v0.3 — so ignore fields you don't know. Your decision's contract — the `result` keys and their types — changes only when **you** deploy a new version, and the response's `version` tells which version answered.
The API is a single HTTPS call from any language. The official [SDKs](https://docs.dcision.io/docs/sdks) for TypeScript and Python add safe retries, function destinations and webhook verification — build them from source until they are published on npm and PyPI. For the terminal and CI, use the [CLI](https://docs.dcision.io/docs/cli).
---
# Authentication
> Authenticate with Bearer API keys (dcs_live_ and dcs_test_) — create, store, rotate and revoke them — and know where they work.
Source: https://docs.dcision.io/docs/api/authentication
Every call to the public API needs an API key in the `Authorization` header:
```http
Authorization: Bearer dcs_live_9fK2mQ7xLp4Rt8Vw1Zc6Nb3Hy5Js0Ad2
```
A key is `dcs_live_` or `dcs_test_` followed by 32 letters and digits. A missing header, another scheme than `Bearer`, a malformed, unknown or revoked key all fail with:
```json title="401 Unauthorized"
{
"error": {
"code": "INVALID_API_KEY",
"message": "Missing, invalid or revoked API key.",
"request_id": "req_Qm8vT2kLx9Pw4Rz7Nc1B"
}
}
```
## Create a key [#create-a-key]
1. In the app, open **API Keys** and click **New key**.
2. Give it a name (for example the service that will use it) and choose the environment: **Live** (`dcs_live_…`) or **Test** (`dcs_test_…`).
3. Click **Create key** and copy it. **The full key is shown only once.**
Store it in your secrets manager or as an environment variable such as `DCISION_API_KEY`. Dcision keeps only a SHA-256 hash of the key, so a lost key can't be recovered — create a new one.
The key list shows each key's name, a masked form (`dcs_live_9fK2…0Ad2`), its environment, when it was created and when it was last used (updated at most once a minute). Members, Admins and the Owner can create, rename and revoke keys; Viewers only see the list — see [Team, roles and account](https://docs.dcision.io/docs/team-and-roles). A workspace can have up to **50 active keys**.
## Live and test keys [#live-and-test-keys]
Both environments behave the same: they call the same deployed versions, share the workspace's rate limit and are billed the same way. The difference is organizational — keep test keys for staging, CI and local development so you can revoke them without touching production — plus one flag: [destination](https://docs.dcision.io/docs/destinations) deliveries of calls made with a test key carry `livemode: false`, so receivers can keep them out of production.
## Scope [#scope]
* A key belongs to **one workspace** and can call **every decision** of that workspace, and read its decisions, account and executions with [`GET /v1/decisions`](https://docs.dcision.io/docs/api/decisions), [`GET /v1/me`](https://docs.dcision.io/docs/api/me) and [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions). The same key authenticates the [MCP server](https://docs.dcision.io/docs/mcp).
* A key from another workspace can't reach your decisions: it gets `404 DECISION_NOT_FOUND`, and its `/v1/executions` only lists its own workspace's runs.
* Keys only work on the public API (`/v1/…`). The app's endpoints refuse them with `403 FORBIDDEN` — "API keys can only call the /v1 decision endpoints."
## Verify a key [#verify-a-key]
`GET /v1/me` answers `200` with the key's workspace, name, environment and plan, or `401 INVALID_API_KEY`. It's a cheap check for CI and deploy scripts — it doesn't run a decision and isn't billed:
```bash
curl -sf https://api.dcision.io/v1/me -H "Authorization: Bearer $DCISION_API_KEY" > /dev/null \
&& echo "key OK" || echo "key rejected"
```
## Rotate a key [#rotate-a-key]
1. Create a new key.
2. Deploy your application with the new key.
3. Revoke the old key once its **Last used** stops moving.
## Revoke a key [#revoke-a-key]
**Revoke** takes effect immediately and can't be undone: every request with the key fails with `401 INVALID_API_KEY`. Revoked keys stay in the list, greyed out, for reference.
## Keep keys safe [#keep-keys-safe]
* Call the API from your server, never from a browser, a mobile app or a client bundle.
* Never commit keys to a repository; load them from the environment.
* Use one key per service so you can revoke one without affecting the others.
---
# Run a decision
> POST /v1/decisions/{slug} — path, query, headers, request body, every response field and the errors, with examples in cURL, JavaScript and Python.
Source: https://docs.dcision.io/docs/api/run-decision
```http
POST https://api.dcision.io/v1/decisions/{slug}
```
Runs the **active version** of the decision `slug` on a state and returns one typed answer per question, the confidence of each answer, the weighted levels and composites, the action chosen by the decision's policies and — for decisions with [destinations](https://docs.dcision.io/docs/destinations) — what happens next. All the questions are answered in one engine call.
## Request [#request]
### Path parameters [#path-parameters]
| Parameter | Description |
| --------- | --------------------------------------------------------------------------------------------------- |
| `slug` | The decision's slug, shown in the editor and the **Deploy** tab — for example `lead-qualification`. |
### Query parameters [#query-parameters]
| Parameter | Description |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `include` | Optional. `probabilities` adds the full [distribution](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#full-distributions) of every question to the response. Comma-separated; other values are ignored. |
### Headers [#headers]
| Header | Required | Description |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Authorization` | Yes | `Bearer dcs_live_…` or `Bearer dcs_test_…` — see [Authentication](https://docs.dcision.io/docs/api/authentication). |
| `Content-Type` | Yes | `application/json`. |
| `Idempotency-Key` | No | 1–128 characters from `A–Z a–z 0–9 . _ : -`. Replays the stored response of a successful call for 24 hours — see [Idempotency](https://docs.dcision.io/docs/api/idempotency). |
| `X-Request-ID` | No | Your correlation ID (same character set). Echoed in the response and stored with the execution. |
### Body [#body]
```json
{ "state": { "message": "We need pricing for 500 users and want to start next month.", "company_size": 500 } }
```
| Field | Type | Description |
| ------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `state` | object, string or array | The input of the decision: an object for an object state, a non-empty string for a text state, a non-empty array (up to 500 items) for a list state. Validated against the decision's [state schema](https://docs.dcision.io/docs/concepts/decision-schema#state-schema); undeclared fields of an object are accepted and forwarded to the engine. |
Other top-level fields are ignored. The whole body must fit in 128 KB, and the state must fit the engine's [token budget](https://docs.dcision.io/docs/concepts/decision-schema#size-limits) together with the questions.
## Examples [#examples]
```bash
curl -X POST https://api.dcision.io/v1/decisions/lead-qualification \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: lead-8421" \
-d '{
"state": {
"message": "We need pricing for 500 users and want to start next month.",
"company_size": 500,
"source": "website"
}
}'
```
```js
const response = await fetch("https://api.dcision.io/v1/decisions/lead-qualification", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DCISION_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "lead-8421",
},
body: JSON.stringify({
state: {
message: "We need pricing for 500 users and want to start next month.",
company_size: 500,
source: "website",
},
}),
signal: AbortSignal.timeout(10_000),
});
const body = await response.json();
if (!response.ok) {
// { error: { code, message, request_id, details? } }
throw new Error(`${response.status} ${body.error.code}: ${body.error.message}`);
}
console.log(body.action, body.result);
```
```python
import os
import requests
response = requests.post(
"https://api.dcision.io/v1/decisions/lead-qualification",
headers={
"Authorization": f"Bearer {os.environ['DCISION_API_KEY']}",
"Idempotency-Key": "lead-8421",
},
json={
"state": {
"message": "We need pricing for 500 users and want to start next month.",
"company_size": 500,
"source": "website",
}
},
timeout=10,
)
body = response.json()
if not response.ok:
# {"error": {"code", "message", "request_id", "details"?}}
raise RuntimeError(f"{response.status_code} {body['error']['code']}: {body['error']['message']}")
print(body["action"], body["result"])
```
A decision with a **text state** takes a string:
```bash
curl -X POST https://api.dcision.io/v1/decisions/spam-detection \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "state": "Congratulations! You won a $1000 gift card, click here to claim now." }'
```
A decision with a **list state** takes an array — here a chat transcript, for a decision named `chat-triage` whose state is a list of strings:
```bash
curl -X POST https://api.dcision.io/v1/decisions/chat-triage \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "state": ["Hi!", "Can I get a quote for 50 seats?"] }'
```
## Response [#response]
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Request-ID: req_Qm8vT2kLx9Pw4Rz7Nc1B
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1791195060
```
```json
{
"decision_id": "dec_3fKq9ZtW1mXcV7bN2pLa",
"execution_id": "exec_8HsT2kQw9ZyR4vMn1cXe",
"schema": "lead-qualification",
"version": 1,
"result": {
"purchase_intent": 0.9412,
"priority": "high",
"route": "sales"
},
"confidence": {
"purchase_intent": 0.8824,
"priority": 0.69,
"route": 0.85
},
"scores": { "priority": 2.81 },
"composites": { "lead_score": 0.8414 },
"action": "continue",
"action_reason": { "type": "default" },
"metrics": {
"latency_ms": 412,
"engine": "jev",
"model": "jev-1.13.0",
"estimated_cost_usd": 0.00001575,
"input_tokens": 375,
"output_tokens": 36
}
}
```
| Field | Type | Description |
| ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `decision_id` | string | The decision's ID (`dec_…`). |
| `execution_id` | string | The ID of this run (`exec_…`), searchable in **Executions** and `GET /v1/executions`. |
| `schema` | string | The decision's slug. |
| `version` | integer | The deployed version that answered. |
| `result` | object | One answer per question, in question order: an option (choice), the label of the most likely level (score) or the probability of *yes* from 0 to 1 (probability). Empty after an engine-error fallback. |
| `confidence` | object | One number from 0 to 1 per question — see [Confidence](https://docs.dcision.io/docs/concepts/confidence-and-probabilities). |
| `scores` | object | Only for decisions with score questions: the weighted level of each one, counted from 1 — see [Weighted levels](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#weighted-levels). |
| `composites` | object | Only for decisions with [composites](https://docs.dcision.io/docs/concepts/composites): the value of each one. |
| `action` | string | `continue`, `block`, `escalate` or `fallback` — see [Policies and actions](https://docs.dcision.io/docs/concepts/policies-and-actions). |
| `action_reason` | object | Why the action was chosen (below). |
| `probabilities` | object | Only with `?include=probabilities`: the distribution of every question. |
| `destinations` | array | Only for decisions with [destinations](https://docs.dcision.io/docs/destinations): one entry per destination that fired, in schema order — `[]` when none did. See [below](#destinations). |
| `metrics.latency_ms` | integer | Time the run took inside Dcision — state validation, engine call, composites and policies. LLM and agent destinations, which run after it, aren't included. |
| `metrics.engine` | string | `jev`. |
| `metrics.model` | string | The exact model version that answered, for example `jev-1.13.0` — also when the workspace uses an alias such as `jev-latest`. |
| `metrics.estimated_cost_usd` | number | Estimated engine cost of the call: input tokens × the model's price. |
| `metrics.input_tokens` | integer | Input tokens of the engine call — what Jev bills. |
| `metrics.output_tokens` | integer | Output tokens of the engine call, for observability: Jev doesn't bill them. |
### `action_reason` [#action_reason]
| `type` | Extra fields | Meaning |
| ---------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy` | `rule` | Policy rule number `rule` matched (0-based). |
| `other_option` | `question` | That choice question answered `other`; the fallback action applies. |
| `low_confidence` | `question`, `confidence`, `minConfidence` | That question's confidence was below its minimum; the fallback action applies. |
| `engine_error` | `code` | The engine failed with the error `code` and the decision's `onEngineError` is `fallback`: the fallback action applies, `result` is empty and the call isn't billed. |
| `default` | — | Nothing matched: `continue`. |
`result`, `confidence`, `scores` and `probabilities` are keyed by your question keys, and `composites` by your composite keys, so they never collide with the top-level fields: the Spam Detection template's `result.action` (its `action` question: `allow`, `review` or `block`) is distinct from the top-level `action` chosen by its policies.
### `destinations` [#destinations]
Present when the decision has destinations. Each entry describes one destination that fired — what it returned, or what Dcision queued — and destinations never fail the call: a problem shows up on its entry.
```json title="Every kind of entry"
[
{
"key": "sales_reply",
"type": "reply",
"text": "Thanks! Someone from our sales team will contact you today.",
"buttons": ["Book a demo", "See pricing"]
},
{
"key": "sales_llm",
"type": "llm",
"status": "completed",
"text": "Thanks for reaching out! Someone from our sales team will contact you today to talk about your 500 users.",
"model": "openai/gpt-5-mini",
"usage": { "input_tokens": 61, "output_tokens": 27 }
},
{
"key": "sales_agent",
"type": "agent",
"status": "completed",
"reply": "I booked a demo for tomorrow at 10:00. You'll get an invitation by e-mail.",
"response": { "reply": "I booked a demo for tomorrow at 10:00. You'll get an invitation by e-mail.", "booking_id": "bk_5521" }
},
{ "key": "sales_summary", "type": "llm", "status": "failed", "error": "No answer from OpenRouter within 20 s." },
{ "key": "sales_crm", "type": "workflow", "status": "queued", "delivery_id": "dlv_8kJx2mQp4LzN7vR1tY6w" },
{ "key": "notify_sales", "type": "webhook", "status": "queued", "delivery_id": "dlv_Q2w9Lm4Xc7Rv1Tz8Wn3B" },
{ "key": "handoff", "type": "agent", "status": "queued", "delivery_id": "dlv_Hq2Lm9Xc4Rv1Tz8Wn3Bk" },
{ "key": "assign_owner", "type": "function", "function": "assignToSales", "params": { "email": "ana@acme.com", "route": "sales" } },
{ "key": "create_ticket", "type": "http", "status": "skipped", "reason": "missing_param", "param": "ticket_id" }
]
```
| Field | Type | Description |
| --------------------- | -------------- | ----------------------------------------------------------------------------------------------------- |
| `key` | string | The destination's key in the decision. |
| `type` | string | `reply`, `llm`, `agent`, `workflow`, `webhook`, `http` or `function`. |
| `status` | string | What happened (below). Fixed replies and functions that fired have no `status`. |
| `text` | string | `reply`: the message. `llm`: the model's answer. |
| `buttons` | array | `reply`: the button labels. |
| `model` | string | `llm`: the model that answered, as reported by the provider. |
| `usage.input_tokens` | integer | `llm`: input tokens counted by the provider — billed by the provider, not by Dcision. |
| `usage.output_tokens` | integer | `llm`: output tokens counted by the provider. |
| `reply` | string or null | `agent` in `sync` mode: the agent's reply, or `null` when its answer had no reply field. |
| `response` | any | `agent` in `sync` mode: the agent's whole answer, or `null` when it is larger than 16,000 characters. |
| `error` | string | `failed` entries: why the LLM or agent call failed. |
| `delivery_id` | string | `queued` entries: the `dlv_…` ID sent with every attempt — deduplicate on it. |
| `function` | string | `function`: the handler your code should call. |
| `params` | object | `function`: the arguments of the call. |
| `reason` | string | `skipped` entries: why the destination didn't run (below). |
| `param` | string | `skipped` with `missing_param`: the required param that had no value. |
| `status` | Types | Meaning |
| ----------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `completed` | `llm`, `agent` | The model or the agent answered — the caller waited for it. |
| `failed` | `llm`, `agent` | The call failed; the decision is still valid. See `error`. |
| `queued` | `webhook`, `http`, `workflow`, `agent` | Dcision delivers it in the background, with retries — see [Delivery and retries](https://docs.dcision.io/docs/destinations/delivery). |
| `skipped` | all | The destination matched but didn't run. |
| `reason` | Meaning |
| --------------- | ------------------------------------------------------------------------------------------------------------ |
| `missing_param` | A param marked `required` had no value. |
| `limit` | More than 10 destinations matched, or more than 3 that make the caller wait (LLM answers and `sync` agents). |
| `unavailable` | The delivery couldn't be queued. |
In the Playground, entries are previews — `"status": "preview"` with the request or prompt that would be sent — unless you run the destinations for real. See [Testing destinations](https://docs.dcision.io/docs/destinations/testing).
## Errors [#errors]
Errors use the [standard format](https://docs.dcision.io/docs/api/errors). The ones this endpoint returns:
| Status | Codes |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `INVALID_REQUEST` — malformed JSON, a body that isn't an object, an invalid `Idempotency-Key` |
| 401 | `INVALID_API_KEY` |
| 402 | `CREDITS_EXHAUSTED` — the included volume is used up and there are no credits left; `SPEND_CAP_REACHED` — the cycle's spend cap is reached; `QUOTA_EXCEEDED` — the included volume of a plan with a hard cap is used up |
| 404 | `DECISION_NOT_FOUND` |
| 409 | `DECISION_NOT_DEPLOYED`, `DECISION_DISABLED`, `IDEMPOTENCY_CONFLICT` — with `Retry-After: 1` while a request with the same key is still running |
| 413 | `PAYLOAD_TOO_LARGE` — body larger than 128 KB |
| 422 | `INVALID_STATE` — including a state over the engine's token budget — and `INVALID_SCHEMA` |
| 424 | `ENGINE_NOT_CONFIGURED` |
| 429 | `RATE_LIMITED` |
| 500 | `INTERNAL_ERROR` |
| 502 | `ENGINE_AUTH_FAILED`, `ENGINE_INVALID_REQUEST`, `ENGINE_ERROR` |
| 503 | `ENGINE_RATE_LIMITED`, `ENGINE_UNAVAILABLE` |
| 504 | `ENGINE_TIMEOUT` |
Engine errors come after Dcision's own retries, within the decision's `timeoutMs`. When the decision's `onEngineError` is `fallback`, the `502`, `503` and `504` engine errors become a `200` with the fallback action and `action_reason.type = "engine_error"` — see [Decision settings](https://docs.dcision.io/docs/concepts/settings#onengineerror).
## Order of checks [#order-of-checks]
When several things are wrong, the first failing step decides the error:
1. **API key** → `401 INVALID_API_KEY`.
2. **Rate limit** of the workspace → `429 RATE_LIMITED`. From here on, responses carry the `X-RateLimit-*` headers.
3. **Slug format** → `404 DECISION_NOT_FOUND`; **body** → `400 INVALID_REQUEST`.
4. **Idempotency**: a stored response for the same key and request is replayed now; the same key with another request → `409 IDEMPOTENCY_CONFLICT`; the same request still running → `409 IDEMPOTENCY_CONFLICT` with `Retry-After: 1`. Otherwise the key is reserved for this request — and freed again if any later step fails.
5. **Decision** exists, is enabled and has a deployed version → `404` / `409`.
6. **Included volume and credits**: past the plan's included decisions, the available credits → `402 CREDITS_EXHAUSTED`, then the spend cap → `402 SPEND_CAP_REACHED`; a plan with a hard cap → `402 QUOTA_EXCEEDED`.
7. **State** validation, then the engine's **token budget** for the state and the questions → `422 INVALID_STATE`.
8. **Engine** call, with retries inside `timeoutMs` → `424` / `502` / `503` / `504` on failure, or the fallback action with `onEngineError: "fallback"`.
9. **Composites and policies** → `action` and `action_reason`.
10. **Destinations** → replies and functions are returned, LLM and `sync` agent calls are awaited, deliveries are queued — then `200 OK`.
Steps 7 to 10 are recorded as [executions](https://docs.dcision.io/docs/concepts/executions-and-usage), failures included. Only a fresh `200` from step 10 is billed — not errors, not replays, not engine-error fallbacks. See [Plans, quotas and billing](https://docs.dcision.io/docs/plans-and-billing#what-is-billed).
---
# List and get decisions
> GET /v1/decisions and GET /v1/decisions/{slug} — the decisions of an API key's workspace and the contract of each one's active version, with an example state.
Source: https://docs.dcision.io/docs/api/decisions
```http
GET https://api.dcision.io/v1/decisions
GET https://api.dcision.io/v1/decisions/{slug}
```
Two read endpoints tell an integration — or an agent through the [MCP server](https://docs.dcision.io/docs/mcp) — which decisions exist and what each one accepts and answers, without opening the app. They only show what the **active version** exposes: a draft that was never deployed is listed, but its schema stays private until you deploy it.
Both use the same API keys as decisions, share the read [rate-limit window](https://docs.dcision.io/docs/api/rate-limits) with [`GET /v1/me`](https://docs.dcision.io/docs/api/me) and [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions), and are not billed.
## List decisions [#list-decisions]
```bash
curl https://api.dcision.io/v1/decisions \
-H "Authorization: Bearer $DCISION_API_KEY"
```
```json title="200 OK"
{
"data": [
{
"slug": "lead-qualification",
"name": "Lead Qualification",
"description": "Qualify inbound leads and route them to the right team.",
"status": "deployed",
"version": 3,
"updated_at": "2026-10-05T13:41:09.204Z"
},
{
"slug": "chat-triage",
"name": "Chat triage",
"description": null,
"status": "draft",
"version": null,
"updated_at": "2026-10-04T18:02:55.731Z"
}
]
}
```
| Field | Type | Description |
| -------------------- | --------------- | ------------------------------------------------------------------------- |
| `data` | array | The workspace's decisions, most recently updated first. |
| `data[].slug` | string | The decision's slug — its endpoint, `POST /v1/decisions/{slug}`. |
| `data[].name` | string | The decision's name. |
| `data[].description` | string or null | The decision's description. |
| `data[].status` | string | `draft` (never deployed), `deployed` or `disabled` (endpoint turned off). |
| `data[].version` | integer or null | The active version; `null` for a draft. |
| `data[].updated_at` | string | When the decision last changed, ISO 8601. |
Only `deployed` decisions can run: the others answer `409 DECISION_NOT_DEPLOYED` or `409 DECISION_DISABLED`.
## Get a decision [#get-a-decision]
```bash
curl https://api.dcision.io/v1/decisions/lead-qualification \
-H "Authorization: Bearer $DCISION_API_KEY"
```
```json title="200 OK"
{
"slug": "lead-qualification",
"name": "Lead Qualification",
"description": "Qualify inbound leads and route them to the right team.",
"status": "deployed",
"version": 3,
"state_schema": {
"type": "object",
"required": ["message"],
"properties": {
"message": { "type": "string", "description": "Inbound lead message" },
"company_size": { "type": "number", "description": "Employee count" },
"source": { "type": "string", "description": "website, referral, ads" }
}
},
"questions": [
{
"id": "purchase_intent",
"type": "probability",
"prompt": "Does this lead have real intent to buy in the next 30 days?",
"options": ["yes", "no"]
},
{
"id": "priority",
"type": "score",
"prompt": "How urgent is it to answer this lead?",
"options": ["low", "medium", "high", "critical"]
},
{
"id": "route",
"type": "choice",
"prompt": "Which team should receive this lead?",
"options": ["sales", "sdr", "nurture", "spam", "other"]
}
],
"composites": [
{ "id": "lead_score", "description": "0–1 ranking: intent counts double, priority adds, spam-like routes don't count" }
],
"actions": ["continue", "block", "escalate", "fallback"],
"fallback_action": "escalate",
"example_state": { "message": "", "company_size": 1.5, "source": "" }
}
```
| Field | Type | Description |
| ---------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `slug` | string | The decision's slug. |
| `name` | string | The decision's name. |
| `description` | string or null | The decision's description. |
| `status` | string | `draft`, `deployed` or `disabled`. |
| `version` | integer or null | The active version; `null` for a draft. |
| `state_schema` | object or null | The state the active version accepts, as JSON Schema — the **API contract** of the editor: `{ "type": "object", "required", "properties" }`, `{ "type": "string" }` for a text state or `{ "type": "array", "items" }` for a list state. `null` for a draft. |
| `questions` | array | The questions, in order. Empty for a draft. |
| `questions[].id` | string | The question key — the key of its answer in `result` and `confidence`. |
| `questions[].type` | string | `choice`, `score` or `probability`. |
| `questions[].prompt` | string, object or array | The question's instructions, as written in the schema. |
| `questions[].options` | array | What the answer can be: the options of a choice question, `other` included; the level labels of a score question, lowest first; `["yes", "no"]` for a probability question, whose `result` is the probability of *yes*. |
| `questions[].min_confidence` | number | Only when the question has a `minConfidence`: below it, the fallback action applies. |
| `composites` | array | Only for decisions with [composites](https://docs.dcision.io/docs/concepts/composites): each one's `id` and `description`. |
| `actions` | array | The actions a response can carry: `continue`, `block`, `escalate` and `fallback`. Empty for a draft. |
| `fallback_action` | string | The action for an `other` answer or low confidence — see [Policies and actions](https://docs.dcision.io/docs/concepts/policies-and-actions#how-the-action-is-computed). Absent for a draft. |
| `example_state` | any | A placeholder state with the right shape — strings as ``, numbers as `1.5`, integers as `1` — to start from. Replace the values before running. `null` for a draft. |
A draft answers `200` with `version`, `state_schema` and `example_state` set to `null` and empty `questions` and `actions`. Policies, destinations, settings and the decision's `context` are never returned.
## Errors [#errors]
| Status | Code | When |
| ------ | -------------------- | ----------------------------------------------------------------------- |
| 401 | `INVALID_API_KEY` | The key is missing, malformed, unknown or revoked. |
| 404 | `DECISION_NOT_FOUND` | No decision with this slug in the key's workspace, or a malformed slug. |
| 429 | `RATE_LIMITED` | Too many read requests in the current minute. |
A key only sees its own workspace's decisions.
---
# Get account and usage
> GET /v1/me — the workspace, API key and plan behind a key, plus this billing period's usage. Verify a key in CI and watch your quota from code.
Source: https://docs.dcision.io/docs/api/me
```http
GET https://api.dcision.io/v1/me
```
Returns who an API key belongs to — its workspace, the key itself and the plan — and the billable decisions used in the current billing period. Use it to verify a key in CI or at boot, and to show or alert on quota from your own code.
## Request [#request]
No parameters and no body. Authenticate with the same API keys as decisions:
```bash
curl https://api.dcision.io/v1/me \
-H "Authorization: Bearer $DCISION_API_KEY"
```
## Response [#response]
```json title="200 OK"
{
"workspace": { "id": "4c8f2a91-6d3b-4e7a-9f15-2b0c7d8e6a43", "name": "Acme" },
"api_key": {
"id": "key_7Hq2Lm9Xc4Rv1Tz8Wn3B",
"name": "Production",
"prefix": "dcs_live_9fK2…0Ad2",
"environment": "live"
},
"plan": {
"id": "growth",
"name": "Growth",
"rate_limit_per_minute": 2000,
"max_decisions": null,
"log_retention_days": 30,
"hard_cap": false
},
"usage": {
"used": 1284311,
"included": 10000000,
"period_start": "2026-10-01T00:00:00.000Z",
"period_end": "2026-11-01T00:00:00.000Z"
}
}
```
| Field | Type | Description |
| ---------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspace.id` | string | The workspace's ID. |
| `workspace.name` | string | The workspace's name. |
| `api_key.id` | string | The key's ID (`key_…`). |
| `api_key.name` | string | The name given to the key in **API Keys**. |
| `api_key.prefix` | string | The masked key, as shown in the app — never the full secret. |
| `api_key.environment` | string | `live` or `test`. |
| `plan.id` | string | `genesis`, `developer`, `growth` or `enterprise`. |
| `plan.name` | string | The plan's display name. |
| `plan.rate_limit_per_minute` | integer | Requests per minute allowed for the workspace — see [Rate limits](https://docs.dcision.io/docs/api/rate-limits). |
| `plan.max_decisions` | integer or null | Decisions the workspace can have; `null` is unlimited. |
| `plan.log_retention_days` | integer | How long the plan keeps executions. |
| `plan.hard_cap` | boolean | `true` when the plan can't continue with credits: calls are blocked with `402 QUOTA_EXCEEDED` once the included volume is used up. `false` on Genesis, Developer and Growth — past the included volume they use [credits](https://docs.dcision.io/docs/plans-and-billing#credits). |
| `usage.used` | integer | Billable decisions in the current period. |
| `usage.included` | integer | Decisions included in the current period — on annual plans, the pool for the whole year. |
| `usage.period_start` | string | Start of the current billing period, ISO 8601. |
| `usage.period_end` | string | End of the current billing period, ISO 8601. |
The billing period is the subscription cycle on paid plans and the calendar month in UTC on Genesis — see [Plans, quotas and billing](https://docs.dcision.io/docs/plans-and-billing#billing-period).
## Examples [#examples]
Fail a CI job early when the key is wrong:
```bash
dcision_me=$(curl -sf https://api.dcision.io/v1/me -H "Authorization: Bearer $DCISION_API_KEY") \
|| { echo "Invalid Dcision API key" >&2; exit 1; }
echo "$dcision_me" | jq -r '"\(.workspace.name) · \(.api_key.environment) · \(.plan.name)"'
```
Warn before the included volume runs out — past it, decisions use credits:
```js
const me = await fetch("https://api.dcision.io/v1/me", {
headers: { Authorization: `Bearer ${process.env.DCISION_API_KEY}` },
}).then((response) => response.json());
const share = me.usage.used / me.usage.included;
if (share >= 0.9) alert(`Dcision: ${Math.round(share * 100)}% of the included volume used`);
```
## Errors [#errors]
| Status | Code | When |
| ------ | ----------------- | -------------------------------------------------- |
| 401 | `INVALID_API_KEY` | The key is missing, malformed, unknown or revoked. |
| 429 | `RATE_LIMITED` | Too many read requests in the current minute. |
Read endpoints — `GET /v1/me` and [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions) — share a rate-limit window of their own, with your plan's per-minute limit, so polling them never slows down your decisions. Reads are not billed.
---
# List executions
> GET /v1/executions — recent runs of your decisions with answers, action, reason and metrics, filtered by decision and status and paginated with a cursor. Inputs are never returned.
Source: https://docs.dcision.io/docs/api/executions
```http
GET https://api.dcision.io/v1/executions
```
Lists the [executions](https://docs.dcision.io/docs/concepts/executions-and-usage) of the API key's workspace, newest first: each run's answers, action and reason, destinations, status and error code, and metrics. Use it to monitor a deployment, export outcomes to your warehouse or calibrate thresholds against what really happened.
The state of a run may contain personal data, so this endpoint never returns it. Workspace members can see stored inputs in the app's **Executions** screen.
## Request [#request]
### Query parameters [#query-parameters]
| Parameter | Description |
| ---------- | ------------------------------------------------------------- |
| `limit` | Executions per page, 1 to 100. Default 20. |
| `decision` | Only the executions of the decision with this slug. |
| `status` | `success` or `error`. |
| `cursor` | The `next_cursor` of the previous page, to read the next one. |
An invalid value fails with `400 INVALID_REQUEST` — for example `limit=1000` or a malformed `cursor`.
```bash
curl "https://api.dcision.io/v1/executions?decision=lead-qualification&status=error&limit=50" \
-H "Authorization: Bearer $DCISION_API_KEY"
```
## Response [#response]
```json title="200 OK"
{
"data": [
{
"execution_id": "exec_8HsT2kQw9ZyR4vMn1cXe",
"decision": "lead-qualification",
"version": 1,
"source": "api",
"status": "success",
"error_code": null,
"result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
"confidence": { "purchase_intent": 0.8824, "priority": 0.69, "route": 0.85 },
"action": "continue",
"action_reason": { "type": "default" },
"destinations": [
{ "key": "sales_crm", "type": "workflow", "status": "queued", "delivery_id": "dlv_8kJx2mQp4LzN7vR1tY6w" }
],
"metrics": { "latency_ms": 412, "engine": "jev", "model": "jev-1.13.0", "estimated_cost_usd": 0.00001575 },
"created_at": "2026-10-05T14:03:27.512Z"
},
{
"execution_id": "exec_2Lm7Qw4Xz9Tk1Rb6Vn3C",
"decision": "lead-qualification",
"version": 1,
"source": "api",
"status": "error",
"error_code": "INVALID_STATE",
"result": null,
"confidence": null,
"action": null,
"action_reason": null,
"destinations": null,
"metrics": { "latency_ms": 2, "engine": "jev", "model": null, "estimated_cost_usd": 0 },
"created_at": "2026-10-05T14:01:09.880Z"
}
],
"next_cursor": "exec_2Lm7Qw4Xz9Tk1Rb6Vn3C"
}
```
| Field | Type | Description |
| ---------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data` | array | The executions of this page, newest first. |
| `data[].execution_id` | string | The run's ID — the `execution_id` its response returned. |
| `data[].decision` | string | The decision's slug. |
| `data[].version` | integer or null | The version that ran; `null` for Playground runs, which use the draft. |
| `data[].source` | string | `api`, `webhook` (a call to the decision's [webhook URL](https://docs.dcision.io/docs/api/webhook-trigger)) or `playground`. |
| `data[].status` | string | `success` or `error`. |
| `data[].error_code` | string or null | The [error code](https://docs.dcision.io/docs/api/errors) of a failed run. |
| `data[].result` | object or null | The answers, keyed by question. `null` for failed runs and when the decision's `storeOutput` is off. |
| `data[].confidence` | object or null | The confidence of each answer, with the same rules as `result`. |
| `data[].action` | string or null | The action returned; `null` for failed runs. |
| `data[].action_reason` | object or null | Why that action was chosen. |
| `data[].destinations` | array or null | The [destination entries](https://docs.dcision.io/docs/api/run-decision#destinations) the run returned — replies, LLM and agent answers, functions, queued deliveries with their `delivery_id`, skipped destinations. `null` when none fired. A queued entry keeps the status it had when the run answered: the delivery's progress is in the app. |
| `data[].metrics` | object | `latency_ms`, `engine`, `model` and `estimated_cost_usd` of the run. |
| `data[].created_at` | string | When the run happened, ISO 8601. |
| `next_cursor` | string or null | Pass it as `cursor` to read the next page; `null` on the last page. |
Failed runs are listed too — invalid states, engine errors — so you can see what your integration sends. A run answered with the [engine-error fallback](https://docs.dcision.io/docs/concepts/settings#onengineerror) appears with status `error` and the engine's error code, even though the call itself returned `200`.
Only executions still within your [log retention](https://docs.dcision.io/docs/concepts/executions-and-usage#retention) are listed.
The state itself is never returned, but `destinations` is kept as the run returned it — whatever the decision's `storeInput` and `storeOutput` settings: function params mapped from the state, fixed replies, LLM texts and agent answers included. Map only the values your code needs into params.
## Paginate [#paginate]
```js
async function* executions(params = {}) {
let cursor;
do {
const query = new URLSearchParams({ limit: "100", ...params, ...(cursor ? { cursor } : {}) });
const page = await fetch(`https://api.dcision.io/v1/executions?${query}`, {
headers: { Authorization: `Bearer ${process.env.DCISION_API_KEY}` },
}).then((response) => response.json());
yield* page.data;
cursor = page.next_cursor;
} while (cursor);
}
for await (const run of executions({ decision: "voice-banking", status: "success" })) {
console.log(run.created_at, run.result?.intent, run.confidence?.intent, run.action);
}
```
## Errors [#errors]
| Status | Code | When |
| ------ | ----------------- | ------------------------------------------------------------------------------------------------------- |
| 400 | `INVALID_REQUEST` | A query parameter is invalid: `limit` outside 1–100, a malformed `decision` slug, `status` or `cursor`. |
| 401 | `INVALID_API_KEY` | The key is missing, malformed, unknown or revoked. |
| 429 | `RATE_LIMITED` | Too many read requests in the current minute. |
`GET /v1/executions` shares the read rate-limit window with [`GET /v1/me`](https://docs.dcision.io/docs/api/me), separate from decisions. A key only sees its own workspace's executions.
---
# Webhook trigger
> POST /v1/hooks/{token} — run a deployed decision from a secret URL without an API key, from forms, CRMs, Zapier, n8n, Make or any backend, optionally signed with a whsec_ secret.
Source: https://docs.dcision.io/docs/api/webhook-trigger
```http
POST https://api.dcision.io/v1/hooks/whk_…
```
Every decision can have a **webhook URL**: a secret address that runs the decision's active version when something posts to it — a form, a CRM, Zapier, n8n, Make, Pipedream or another backend. It needs no API key, so tools that can only call a URL can trigger a decision, and you can add a **secret** so that only callers who know it are accepted.
The webhook trigger is **inbound**: someone calls Dcision. [Webhook destinations](https://docs.dcision.io/docs/destinations/webhooks) go the other way — Dcision calls you after it decides — and use the [`decision.completed` event](https://docs.dcision.io/docs/api/webhooks).
## Set it up [#set-it-up]
Open the decision in the [app](https://app.dcision.io) and go to its **Advanced** tab:
1. **Turn the webhook on.** Dcision creates the URL: `https://api.dcision.io/v1/hooks/whk_` followed by 32 letters and digits. Copy it.
2. **Generate a secret** (recommended). The full `whsec_…` value is shown **only once** — store it where the caller can read it, for example as `DCISION_WEBHOOK_SECRET`.
3. **Send a test** from the tab's console: Dcision calls the real URL, signed with the secret, and shows the request and the response. The test runs the decision like any caller and **counts as a run**.
The tab also lists the latest calls as they arrive, refusals included, and has ready-made cURL, Node.js and Python snippets with your URL. Turning the webhook on or off needs the **Member** role; rotating the URL and generating, replacing or removing the secret need **Admin** — see [Team and roles](https://docs.dcision.io/docs/team-and-roles).
The URL runs the **deployed** version: deploy the decision before you point a system at it.
## Request [#request]
### Path [#path]
| Parameter | Description |
| --------- | ----------------------------------------------------------------------------------------------- |
| `token` | The `whk_…` token of the decision's webhook. It identifies both the decision and the workspace. |
### Query parameters [#query-parameters]
| Parameter | Description |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `include` | Optional. `probabilities` adds the full distribution of every question, as in [Run a decision](https://docs.dcision.io/docs/api/run-decision#query-parameters). |
### Headers [#headers]
| Header | Required | Description |
| ------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type` | Recommended | `application/json` (any other value is read as JSON too) — or `text/plain` to send a [text state](https://docs.dcision.io/docs/concepts/decision-schema#text-state) as is. |
| `Dcision-Signature` | With a secret | `t=,v1=` — see [Sign the request](#sign-the-request). |
| `Authorization` | With a secret, instead of the signature | `Bearer whsec_…` — the secret itself, for tools that can't compute an HMAC. |
`Idempotency-Key` isn't supported on webhook URLs: a retried call runs — and is billed — again.
### Body [#body]
Send the state wrapped, as for the API, or the payload itself:
```json title="Wrapped"
{ "state": { "message": "Can I get a demo next week?", "company_size": 80 } }
```
```json title="Raw payload"
{ "message": "Can I get a demo next week?", "company_size": 80 }
```
When the body is a JSON object whose **only** key is `state`, Dcision uses the value of `state`. Anything else — an object with other keys, an array, a string — **is** the state. That lets a third-party tool post its own payload unchanged; its fields must still match the decision's [state schema](https://docs.dcision.io/docs/concepts/decision-schema#state-schema).
If the system you connect sends something like `{ "state": "CA" }` — a field that happens to be called `state` and nothing else — Dcision unwraps it and the state becomes `"CA"`. Wrap such payloads yourself: `{ "state": { "state": "CA" } }`.
The body must be at most **128 KB**.
## Sign the request [#sign-the-request]
With a secret, every call must prove it knows it, in one of two ways.
**`Dcision-Signature`** (recommended) — the same scheme Dcision uses to sign its own [webhook deliveries](https://docs.dcision.io/docs/destinations/webhooks#verify-the-signature):
```text
signed_payload = "." +
v1 = hex( HMAC-SHA256( secret, signed_payload ) )
header = Dcision-Signature: t=,v1=
```
`t` is the current Unix time in seconds; calls whose `t` is more than **300 seconds** away from Dcision's clock are refused, so a captured request can't be replayed later. Sign the **exact bytes** you send — serialize the body once and reuse that string.
**`Authorization: Bearer whsec_…`** — the secret itself, compared in constant time. It's simpler for no-code tools, but the secret travels with every request and a captured request can be replayed; prefer the signature when you can compute it. Either proof is enough: if your tool adds its own `Authorization` header (a credential of its own, an API gateway token), a valid `Dcision-Signature` still passes.
```bash
URL="https://api.dcision.io/v1/hooks/whk_…" # from the Advanced tab
BODY='{"state":{"message":"Can I get a demo next week?","company_size":80}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$DCISION_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Dcision-Signature: t=$TS,v1=$SIG" \
-d "$BODY"
```
```js
import crypto from "node:crypto";
const url = process.env.DCISION_WEBHOOK_URL; // https://api.dcision.io/v1/hooks/whk_…
const body = JSON.stringify({ state: { message: "Can I get a demo next week?", company_size: 80 } });
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac("sha256", process.env.DCISION_WEBHOOK_SECRET).update(`${t}.${body}`).digest("hex");
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "Dcision-Signature": `t=${t},v1=${v1}` },
body, // the exact string that was signed
});
console.log(response.status, await response.json());
```
```python
import hashlib
import hmac
import json
import os
import time
import requests
url = os.environ["DCISION_WEBHOOK_URL"] # https://api.dcision.io/v1/hooks/whk_…
body = json.dumps({"state": {"message": "Can I get a demo next week?", "company_size": 80}})
t = str(int(time.time()))
v1 = hmac.new(os.environ["DCISION_WEBHOOK_SECRET"].encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
response = requests.post(
url,
data=body.encode(), # the exact bytes that were signed
headers={"Content-Type": "application/json", "Dcision-Signature": f"t={t},v1={v1}"},
timeout=10,
)
print(response.status_code, response.json())
```
```bash
curl -X POST "https://api.dcision.io/v1/hooks/whk_…" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $DCISION_WEBHOOK_SECRET" \
-d '{"message":"Can I get a demo next week?","company_size":80}'
```
In Zapier, n8n or Make, use an HTTP or webhook step with method `POST`, the URL, a JSON body and a header `Authorization` = `Bearer whsec_…` stored as a credential.
## Without a secret [#without-a-secret]
A webhook without a secret accepts any call to its URL: the URL itself is the credential. That's fine for a quick test or a trusted internal network, but anyone who sees the URL — in a tool's logs, a shared screenshot, a browser's history — can run the decision, and every run is billed to your workspace.
* Add a secret before you put the URL in a third-party tool, and keep the secret out of the URL.
* If a URL leaks, **rotate** it in the Advanced tab: the old URL stops working at once and answers `404`.
* Turning the webhook off makes the URL answer `404 NOT_FOUND`, the same answer as a URL that never existed.
Removing the secret makes the URL accept unsigned calls again.
## Response [#response]
The same response as [`POST /v1/decisions/{slug}`](https://docs.dcision.io/docs/api/run-decision#response) — `result`, `confidence`, `scores`, `composites`, `action`, `action_reason`, `destinations` and `metrics`:
```json title="200 OK"
{
"decision_id": "dec_3fKq9ZtW1mXcV7bN2pLa",
"execution_id": "exec_8HsT2kQw9ZyR4vMn1cXe",
"schema": "lead-qualification",
"version": 1,
"result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
"confidence": { "purchase_intent": 0.8824, "priority": 0.69, "route": 0.85 },
"scores": { "priority": 2.81 },
"composites": { "lead_score": 0.8414 },
"action": "continue",
"action_reason": { "type": "default" },
"metrics": {
"latency_ms": 412,
"engine": "jev",
"model": "jev-1.13.0",
"estimated_cost_usd": 0.00001575,
"input_tokens": 375,
"output_tokens": 36
}
}
```
Runs through a webhook URL:
* are **billed** like API calls and count toward the plan's included volume — past it they use [credits](https://docs.dcision.io/docs/plans-and-billing#credits), and without credits they answer `402 CREDITS_EXHAUSTED`;
* share the workspace's **decisions rate limit** with the API — see [Rate limits](https://docs.dcision.io/docs/api/rate-limits);
* run the decision's [destinations](https://docs.dcision.io/docs/destinations) with `livemode: true`;
* appear in **Executions**, in the decision's **Overview** with the API traffic, and in [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions) with `source: "webhook"`.
## Errors [#errors]
Errors use the [standard format](https://docs.dcision.io/docs/api/errors), `{ "error": { "code", "message", "request_id" } }`.
| Status | Code | When |
| -------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `INVALID_REQUEST` | An empty body, or a body that isn't valid JSON. |
| 401 | `UNAUTHORIZED` | The webhook has a secret and the call has no valid proof: no `Dcision-Signature` and no `Authorization`, a malformed or wrong signature, a timestamp outside the 300-second window, or a wrong Bearer secret. The message says which. |
| 402 | `CREDITS_EXHAUSTED` | The plan's included volume is used up and there are no credits left. |
| 402 | `SPEND_CAP_REACHED` | The spend cap of the billing cycle is reached. |
| 402 | `QUOTA_EXCEEDED` | The included volume of a plan with a hard cap is used up. |
| 404 | `NOT_FOUND` | Unknown token, a rotated URL, or a webhook that is turned off. |
| 409 | `DECISION_NOT_DEPLOYED` | The decision has never been deployed. |
| 409 | `DECISION_DISABLED` | The decision's endpoint is disabled. |
| 413 | `PAYLOAD_TOO_LARGE` | The body is larger than 128 KB. |
| 422 | `INVALID_STATE` | The state doesn't match the decision's state schema or the engine's token budget. |
| 429 | `RATE_LIMITED` | The workspace's webhook rate limit for the minute is used up (a window of its own, separate from API keys) — or the webhook received more than 30 calls with a wrong signature or secret this minute (`Retry-After: 60`). |
| 424, 5xx | `ENGINE_…`, `INTERNAL_ERROR` | As in [Run a decision](https://docs.dcision.io/docs/api/run-decision#errors). |
Refused calls — a bad signature, invalid JSON, the rate limit — don't become executions, but they appear in the Advanced tab's list of latest calls, so you can see what a misconfigured tool sends.
## Who sees the URL [#who-sees-the-url]
Without a secret, the URL alone runs the decision — billed, with live destinations. So the **Advanced** tab shows it only to members and admins; viewers see that the webhook exists, but not its URL. Rotating the URL and managing the secret are for admins. When someone leaves the team, rotate the URL (and the secret) they could see.
The **Send test request** button runs in live mode as well (destinations fire for real) and counts as a webhook execution. The request it shows has the signature masked, so it can't be replayed.
---
# Webhook events
> The decision.completed event of the OpenAPI document — the request sent to webhook destinations, its headers, every field of the event and the answers Dcision expects.
Source: https://docs.dcision.io/docs/api/webhooks
```http
POST
```
Dcision sends a `decision.completed` event to every [webhook destination](https://docs.dcision.io/docs/destinations/webhooks) that fires. The event is part of the OpenAPI document, served without authentication at `https://api.dcision.io/openapi.json`, under `webhooks` → `decision.completed`.
## Request headers [#request-headers]
| Header | Required | Description |
| ------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Dcision-Signature` | Yes | `t=,v1=` — the HMAC-SHA256 of `.` with your signing secret; two `v1` entries during the 24 hours after a rotation. See [Verify the signature](https://docs.dcision.io/docs/destinations/webhooks#verify-the-signature). |
| `Dcision-Delivery` | Yes | The delivery ID, `dlv_…` — the same on every attempt. |
| `Dcision-Attempt` | Yes | `1` to `6`. |
| `Dcision-Event` | Yes | `decision.completed`. |
| `Content-Type` | Yes | `application/json`. |
| `User-Agent` | Yes | `Dcision-Destinations/1.0 (+https://docs.dcision.io/destinations)`. |
## Body [#body]
```json
{
"id": "dlv_8kJx2mQp4LzN7vR1tY6w",
"type": "decision.completed",
"created": 1759658400,
"livemode": true,
"destination": "notify_sales",
"data": {
"decision": "lead-qualification",
"decision_id": "dec_3fKq9ZtW1mXcV7bN2pLa",
"version": 4,
"execution_id": "exec_8HsT2kQw9ZyR4vMn1cXe",
"result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
"confidence": { "purchase_intent": 0.8824, "priority": 0.69, "route": 0.85 },
"scores": { "priority": 2.81 },
"composites": { "lead_score": 0.8414 },
"action": "continue",
"action_reason": { "type": "default" },
"params": { "message": "We need pricing for 500 users and want to start next month.", "team": "inbound" }
}
}
```
| Field | Type | Description |
| -------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------- |
| `id` | string | The delivery ID, `dlv_…`, the same on every attempt — deduplicate on it. |
| `type` | string | `decision.completed`. Check it: more event types may be added. |
| `created` | integer | When the decision ran, in Unix seconds. |
| `livemode` | boolean | `false` for runs with a test key (`dcs_test_…`) and from the Playground. |
| `destination` | string | The key of the destination that sent the event. |
| `data.decision` | string | The decision's slug. |
| `data.decision_id` | string | The decision's ID, `dec_…`. |
| `data.version` | integer or null | The version that answered; `null` from the Playground, which runs the draft. |
| `data.execution_id` | string | The run's ID, `exec_…` — the `execution_id` of the API response. |
| `data.result` | object | The answers, keyed by question. Empty after an engine-error fallback. |
| `data.confidence` | object | The confidence of each answer, from 0 to 1. |
| `data.scores` | object | The weighted level of each score question; `{}` when there are none. |
| `data.composites` | object | The value of each composite; `{}` when there are none. |
| `data.action` | string | `continue`, `block`, `escalate` or `fallback`. |
| `data.action_reason` | object | Why the action was chosen — see [`action_reason`](https://docs.dcision.io/docs/api/run-decision#action_reason). |
| `data.params` | object | The [params](https://docs.dcision.io/docs/destinations/params-and-templates) mapped in the destination. The state itself is never sent. |
## Responses [#responses]
| Your answer | What Dcision does |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Any `2xx` within 10 seconds | The delivery succeeded. |
| `408`, `425`, `429`, `5xx`, a timeout or a network error | Retries — six attempts over about seven hours, honoring `Retry-After` on `429` and `503`. |
| Any other `4xx`, or a redirect | The delivery fails at once; it can be resent from the app. |
See [Delivery and retries](https://docs.dcision.io/docs/destinations/delivery).
## Other destinations [#other-destinations]
[API requests](https://docs.dcision.io/docs/destinations/http-requests), [workflows](https://docs.dcision.io/docs/destinations/workflows) and [agents](https://docs.dcision.io/docs/destinations/agents) carry the same `Dcision-*` headers and signature — `Dcision-Event` is `decision.completed` for them too — plus an `Idempotency-Key` equal to the delivery ID. Their bodies are different: verify them with `verifySignature` / `verify_signature`, which don't expect an event.
---
# Errors
> The error format and every error code with its HTTP status, what it means and what to do about it — plus which errors are safe to retry.
Source: https://docs.dcision.io/docs/api/errors
## Format [#format]
Every error — from the API or the app's endpoints — has the same shape and never contains a stack trace:
```json
{
"error": {
"code": "INVALID_STATE",
"message": "Field company_size must be a number.",
"request_id": "req_7Gm2xPq9Lk4sVt1RbN8w"
}
}
```
| Field | Description |
| ------------ | ---------------------------------------------------------------------------------------------- |
| `code` | A stable, machine-readable code from the tables below. Switch on it — not on `message`. |
| `message` | A human-readable explanation. It can change; don't parse it. |
| `request_id` | The request's ID, also sent in the `X-Request-ID` header. Include it when you contact support. |
| `details` | Optional extra data, depending on the code (see [details](#details)). |
## Error codes [#error-codes]
### Request and access [#request-and-access]
| Code | Status | Meaning | What to do |
| ------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `INVALID_REQUEST` | 400 | The request is malformed: invalid JSON, a body that isn't a JSON object, an invalid `Idempotency-Key` or query parameter — such as `limit=1000` on `GET /v1/executions`. | Fix the request. `details` lists the offending fields when there are any. |
| `PAYLOAD_TOO_LARGE` | 413 | The request body is larger than 128 KB. | Send a smaller body — for a large state, send only the fields the questions need. See [Limits](https://docs.dcision.io/docs/api/limits). |
| `INVALID_API_KEY` | 401 | The API key is missing, malformed, unknown or revoked. | Send `Authorization: Bearer ` with an active key. |
| `UNAUTHORIZED` | 401 | App: you're not signed in, your session expired or it was ended — by *Sign out everywhere* or a password change; also a wrong e-mail or password at sign-in. [Webhook URLs](https://docs.dcision.io/docs/api/webhook-trigger) with a secret: the call has no valid `Dcision-Signature` or `Authorization: Bearer whsec_…`. | Sign in again — or sign the webhook call with the current secret. |
| `FORBIDDEN` | 403 | You can't do this: an API key used outside `/v1`; a workspace you aren't a member of — then `details.reason` is `"workspace_access"`; or an action your [role](https://docs.dcision.io/docs/team-and-roles#roles) doesn't allow — Viewers can't change anything; settings, provider keys, secrets and members need an Admin; billing, ownership and deleting the workspace need the Owner. | Use API keys only on `/v1`. For a role, ask an Admin or the Owner — the message names the role needed. |
| `NOT_FOUND` | 404 | App: the execution, API key, template, plan, checkout session, member, invitation, delivery or secret doesn't exist in this workspace. [Webhook URLs](https://docs.dcision.io/docs/api/webhook-trigger): the token is unknown, was rotated, or the webhook is turned off. | Check the ID — or copy the webhook URL again from the decision's **Advanced** tab. |
| `CONFLICT` | 409 | The change conflicts with the current state: a slug already taken or locked after the first deploy, a draft saved meanwhile by someone else, a concurrent deploy or create, a limit (50 active API keys, 50 secrets, 50 pending invitations, 10 owned workspaces), an invitation for someone already in the workspace, an Owner leaving without a transfer, a workspace with a live paid subscription being deleted, a subscription that already exists or doesn't. | Reload, then follow the message. |
### Decisions [#decisions]
| Code | Status | Meaning | What to do |
| ----------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `DECISION_NOT_FOUND` | 404 | No decision with this slug in the API key's workspace, or a malformed slug. | Check the slug and that the key belongs to the decision's workspace. |
| `DECISION_NOT_DEPLOYED` | 409 | The decision exists but has never been deployed. | Deploy it from its **Deploy** tab. |
| `DECISION_DISABLED` | 409 | The decision's endpoint is disabled. | Click **Enable endpoint** in its **Deploy** tab. |
| `INVALID_STATE` | 422 | The state doesn't match the decision's state schema, or is too large — by itself, or together with the questions for the engine's token budget. `message` names the field or the limit. | Fix the state; for the token budget, send only the fields the questions need. Don't retry it unchanged. |
| `INVALID_SCHEMA` | 422 | The decision schema is invalid — when saving or deploying a draft. `details` lists every issue. | Fix the issues in the editor (*Go to error*). |
| `IDEMPOTENCY_CONFLICT` | 409 | The `Idempotency-Key` was used in the last 24 hours with a different request — or, with `Retry-After: 1`, the first request with this key is still running. | With `Retry-After`: wait and retry with the same key to get the stored response. Without it: use a new key for a new request — see [Idempotency](https://docs.dcision.io/docs/api/idempotency). |
### Limits and billing [#limits-and-billing]
| Code | Status | Meaning | What to do |
| ------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RATE_LIMITED` | 429 | Too many requests in the current minute for your workspace — or the Playground's own limits, or, in the app, too many billing actions (checkout, plan changes, portal…), invitations, sign-in codes or sign-in attempts. | Wait `Retry-After` seconds, then retry — see [Rate limits](https://docs.dcision.io/docs/api/rate-limits). |
| `CREDITS_EXHAUSTED` | 402 | The plan's included decisions for the period are used up and the workspace has no [credits](https://docs.dcision.io/docs/plans-and-billing#credits) left. | The Owner adds credits or turns on automatic recharge on the **Billing** page (`details.billingUrl`), or upgrades. Retrying before that doesn't help. |
| `SPEND_CAP_REACHED` | 402 | The workspace's [spend cap](https://docs.dcision.io/docs/plans-and-billing#spend-cap) for the billing cycle is reached. | The Owner raises or removes the cap on the **Billing** page, or wait until `details.periodEnd`. |
| `QUOTA_EXCEEDED` | 402 | A plan configured with a hard cap — or without a price per 1M decisions — used all its included decisions for the period, so it can't continue with credits. | Upgrade, or wait until `details.periodEnd`. Retrying earlier doesn't help. |
| `PLAN_LIMIT_REACHED` | 402 | The workspace already has the maximum number of decisions of its plan. | Delete a decision or upgrade. |
| `PAYMENT_FAILED` | 402 | The card was declined while changing plans. | Update the payment method, then try again. |
| `BILLING_NOT_CONFIGURED` | 503 | That plan, interval or currency can't be bought online yet. | Try again later or write to [sales@dcision.io](mailto:sales@dcision.io). |
| `BILLING_PROVIDER_ERROR` | 502 | The payment provider failed to process the request. | Try again in a moment. |
### Engine [#engine]
Dcision has already retried transient engine failures — up to 2 retries within the decision's `timeoutMs` — before it returns one of these errors. A decision with `onEngineError: "fallback"` answers `200` with its fallback action instead, for every code below except `ENGINE_NOT_CONFIGURED` — see [Decision settings](https://docs.dcision.io/docs/concepts/settings#onengineerror).
| Code | Status | Meaning | What to do |
| ------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `ENGINE_NOT_CONFIGURED` | 424 | The workspace has no usable engine credential: *My own provider key* is selected but no key is saved for that provider, or Dcision's engine key is unavailable. | Add the key in **Settings → Engine**, or switch to Dcision's key. |
| `ENGINE_TIMEOUT` | 504 | The engine didn't answer within the decision's `timeoutMs`. | Retry with backoff and the same `Idempotency-Key`; raise `timeoutMs` if it happens often. |
| `ENGINE_RATE_LIMITED` | 503 | The engine provider is overloaded or rate limited, or Dcision's shared engine key had no free slot before the deadline. | Retry with backoff. With your own key, check your provider's limits. |
| `ENGINE_UNAVAILABLE` | 503 | The engine provider failed or couldn't be reached. | Retry with backoff. |
| `ENGINE_INVALID_REQUEST` | 502 | The provider rejected the request; the message includes its reason. | Usually not transient: check the state and the model, then contact support with the request ID. |
| `ENGINE_AUTH_FAILED` | 502 | The provider rejected the engine key. | Fix or replace your provider key in **Settings → Engine**. |
| `ENGINE_ERROR` | 502 | The engine answered outside the decision's contract (a missing or unknown answer). | Retry once; if it persists, contact support with the request ID. |
### Server [#server]
| Code | Status | Meaning | What to do |
| ---------------- | ------ | -------------------------------------- | ----------------------------------------------------------------------- |
| `INTERNAL_ERROR` | 500 | An unexpected error on Dcision's side. | Retry with backoff; contact support with the request ID if it persists. |
## Retrying [#retrying]
| Retry | Don't retry unchanged |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `429 RATE_LIMITED` — after `Retry-After` | `400`, `401`, `403`, `404`, `413`, `422` |
| `409 IDEMPOTENCY_CONFLICT` **with** `Retry-After` — same key, after the wait | Any other `409` |
| `500 INTERNAL_ERROR` | `402 CREDITS_EXHAUSTED`, `SPEND_CAP_REACHED`, `QUOTA_EXCEEDED`, `PLAN_LIMIT_REACHED`, `PAYMENT_FAILED` |
| `503 ENGINE_RATE_LIMITED`, `ENGINE_UNAVAILABLE` | `424 ENGINE_NOT_CONFIGURED` |
| `504 ENGINE_TIMEOUT` | `502 ENGINE_AUTH_FAILED`, `ENGINE_INVALID_REQUEST` |
| `502 ENGINE_ERROR` — once | |
| Network errors and timeouts on your side | |
Send an `Idempotency-Key` so a retry after a lost response doesn't run — or bill — the decision twice. A complete client is in [Idempotent retries](https://docs.dcision.io/docs/guides/idempotent-retries).
Errors are never billed, and neither are engine-error fallbacks. Invalid states and engine failures are recorded as [executions](https://docs.dcision.io/docs/concepts/executions-and-usage) with status *Error*, so you can inspect them — in the app or with [`GET /v1/executions?status=error`](https://docs.dcision.io/docs/api/executions).
## Details [#details]
`details` carries structured data for some codes:
```json title="422 INVALID_SCHEMA"
{
"error": {
"code": "INVALID_SCHEMA",
"message": "The decision schema is invalid.",
"request_id": "req_7Gm2xPq9Lk4sVt1RbN8w",
"details": [{ "path": "questions.0.options", "message": "Add at least 2 options (besides \"other\")." }]
}
}
```
```json title="402 CREDITS_EXHAUSTED"
{
"error": {
"code": "CREDITS_EXHAUSTED",
"message": "The 2.5M decisions included in the Developer plan are used up and the workspace has no credits left. Add credits or turn on automatic recharge.",
"request_id": "req_Lz4Wq8nT1vXc7Rm2Kp9H",
"details": {
"used": 2500000,
"included": 2500000,
"availableCents": 0,
"currency": "usd",
"billingUrl": "https://app.dcision.io/billing"
}
}
}
```
```json title="402 SPEND_CAP_REACHED"
{
"error": {
"code": "SPEND_CAP_REACHED",
"message": "The workspace reached its spend cap of $50.00 for this billing cycle. Raise the cap or wait until 2026-11-01.",
"request_id": "req_Qm8Vr2Lx5Nc1Tw7Hk4Jd",
"details": {
"spendCapCents": 5000,
"spentCents": 5000,
"currency": "usd",
"periodEnd": "2026-11-01T00:00:00.000Z",
"billingUrl": "https://app.dcision.io/billing"
}
}
}
```
```json title="403 FORBIDDEN — not a member of the workspace"
{
"error": {
"code": "FORBIDDEN",
"message": "You don't have access to this workspace.",
"request_id": "req_Pw3Nx8Kd1Lq6Tz2Vb9Rc",
"details": { "reason": "workspace_access" }
}
}
```
| Code | `details` |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INVALID_REQUEST` | `[{ "path", "message" }]` for invalid fields, when available |
| `INVALID_SCHEMA` | `[{ "path", "message" }]` — every schema issue |
| `CREDITS_EXHAUSTED` | `used`, `included`, `availableCents` (the credit left, in cents of `currency` — zero or slightly negative), `currency` (`usd` or `brl`), `billingUrl` |
| `SPEND_CAP_REACHED` | `spendCapCents`, `spentCents` (the cycle's spending with credits, in cents), `currency`, `periodEnd` (ISO 8601), `billingUrl` |
| `QUOTA_EXCEEDED` | `used`, `included`, `periodEnd` (ISO 8601), `upgradeUrl` |
| `PLAN_LIMIT_REACHED` | `limit`, `used`, `upgradeUrl` |
| `FORBIDDEN` (workspace access) | `{ "reason": "workspace_access" }` — you aren't a member of that workspace (any more). Role refusals carry no `details`. |
| `CONFLICT` (stale draft) | `currentRevision` |
| `PAYMENT_FAILED` | `code`, `declineCode` from the card network |
---
# Rate limits
> Per-workspace request limits for each plan, separate windows for decisions and reads, the X-RateLimit headers on every call, Retry-After on 429 and how to back off.
Source: https://docs.dcision.io/docs/api/rate-limits
The public API limits how many requests a **workspace** can make per minute. The limit comes from the workspace's plan:
| Plan | Requests per minute |
| ---------- | ------------------- |
| Genesis | 60 |
| Developer | 300 |
| Growth | 2,000 |
| Enterprise | 10,000 |
The **API Keys** page in the app shows your current limit, and [`GET /v1/me`](https://docs.dcision.io/docs/api/me) returns it as `plan.rate_limit_per_minute`.
## How the limit is counted [#how-the-limit-is-counted]
* **Per workspace, not per key.** All API keys of a workspace share one budget; creating more keys doesn't add capacity.
* **Decisions and reads have separate windows.** `POST /v1/decisions/{slug}` counts in one window; the read endpoints, [`GET /v1/decisions`](https://docs.dcision.io/docs/api/decisions), [`GET /v1/me`](https://docs.dcision.io/docs/api/me) and [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions), in another with the same per-minute limit — so polling them never slows down your decisions. Calls to a decision's [webhook URL](https://docs.dcision.io/docs/api/webhook-trigger) have a third window of their own (same limit, shared by the workspace's webhooks), so a leaked URL can't slow down your API keys. Every request to the [MCP server](https://docs.dcision.io/docs/mcp) (`initialize`, `tools/list`, any tool call) counts with reads, and `run_decision` also counts with decisions. Webhook calls with a wrong signature or secret don't touch these windows: they have their own budget of 30 a minute per webhook (then `429`).
* **Fixed one-minute windows.** A window opens with the first request and lasts 60 seconds; the next request after that opens a new one.
* **Every authenticated request counts** — successful calls, errors and [idempotent replays](https://docs.dcision.io/docs/api/idempotency) alike. Requests with an invalid key are rejected before the limit and don't count.
## Headers [#headers]
Every authenticated response carries the state of its window — the decision window or the read window:
```http
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 12
X-RateLimit-Reset: 1791195060
```
| Header | Description |
| ----------------------- | ----------------------------------------------- |
| `X-RateLimit-Limit` | Requests allowed per window for your workspace. |
| `X-RateLimit-Remaining` | Requests left in the current window. |
| `X-RateLimit-Reset` | When the current window ends, in Unix seconds. |
## When you hit the limit [#when-you-hit-the-limit]
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1791195060
```
```json
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Slow down and retry after the reset time.",
"request_id": "req_Tz6Kq1mW8vLp3Xc9Rn4D"
}
}
```
Wait `Retry-After` seconds, then retry. Requests sent before that keep failing — and keep counting.
```js
async function decide(slug, state) {
for (let attempt = 0; attempt < 3; attempt++) {
const response = await fetch(`https://api.dcision.io/v1/decisions/${slug}`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.DCISION_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ state }),
});
if (response.status !== 429) return response;
const wait = Number(response.headers.get("Retry-After") ?? "1");
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
throw new Error("Still rate limited after 3 attempts");
}
```
## Staying under the limit [#staying-under-the-limit]
* **Watch `X-RateLimit-Remaining`** and slow down before it reaches 0.
* **Smooth bursts** with a queue or a token bucket on your side instead of sending them at once.
* **Don't spread load over more keys** — they share the workspace budget.
* **Need more?** [Upgrade your plan](https://docs.dcision.io/docs/plans-and-billing), or talk to [sales@dcision.io](mailto:sales@dcision.io) about Enterprise.
## Playground limits [#playground-limits]
Playground runs have their own ceiling, separate from the API: **30 runs per minute** and **2,000 runs per day** per workspace. Above it the Playground answers `429 RATE_LIMITED`; use an API key for volume.
## Engine throughput [#engine-throughput]
The engine provider has limits of its own, in requests and tokens per second. On Dcision's engine key they are shared by every workspace, so Dcision smooths bursts: a decision waits for a free slot within its `timeoutMs` instead of failing at once. If none frees up in time, the call fails with `503 ENGINE_RATE_LIMITED` — an engine error, retryable with backoff, not your workspace's `429 RATE_LIMITED`. With your own provider key, your provider account's limits apply. See [Engines and BYOK](https://docs.dcision.io/docs/concepts/engines-and-byok#limits-of-the-engine).
---
# Idempotency
> Retry safely with the Idempotency-Key header — keys reserved before the run, replays of successful calls for 24 hours, the Idempotent-Replayed header and IDEMPOTENCY_CONFLICT.
Source: https://docs.dcision.io/docs/api/idempotency
A timeout or a dropped connection can lose the response of a decision that **did** run. Retrying blindly would run — and bill — it twice. Send an `Idempotency-Key` and a retry gets the stored response instead.
```http
Idempotency-Key: lead-8421
```
The key is 1 to 128 characters from `A–Z a–z 0–9 . _ : -`. Anything else fails with `400 INVALID_REQUEST`.
## Behavior [#behavior]
| Situation | What happens |
| ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| New key | The key is **reserved** for this request before the decision runs. If the decision succeeds (`200`), its response is stored for **24 hours**. |
| Same key, **same request**, within 24 hours | The stored response is returned with the header `Idempotent-Replayed: true` — same `execution_id`, same answers, same destination entries. The decision doesn't run again, nothing is billed, nothing is delivered and no execution is recorded. |
| Same key, **same request**, while the first one is **still running** | `409 IDEMPOTENCY_CONFLICT` with `Retry-After: 1` — "A request with this Idempotency-Key is still running. Retry in a moment to get its response." Retry after a second: once the first request finishes, you get its stored response. The decision never runs twice. |
| Same key, **different request**, within 24 hours | `409 IDEMPOTENCY_CONFLICT` without `Retry-After` — "This Idempotency-Key was already used with a different request body." — even while the first request is running. |
| Same key after 24 hours | Treated as a new key. |
| The first request **failed** | The reservation is released, so a retry with the same key runs the decision again. |
| The first request got the **engine-error fallback** (`action_reason.type = "engine_error"`) | The reservation is released too: a retry with the same key reaches the engine again — and its destinations fire again, with new delivery IDs. |
| The first request **never finished** — the server stopped mid-run | The reservation expires after **2 minutes**; then the key can run again. |
"Same request" means the same **slug**, the same **`state`** and the same **`include`** query value. The state is compared after JSON parsing: whitespace doesn't matter, but **the order of keys does** — `{"a":1,"b":2}` and `{"b":2,"a":1}` are different requests. Build the body the same way on every attempt.
## Choosing keys [#choosing-keys]
Use one key per **logical operation** and reuse it across the retries of that operation:
* the ID of the thing you're deciding about: `lead-8421`, `ticket:9f2c1d`, `msg_01HZX3…`;
* or a UUID generated **once** when the operation starts — not one per attempt.
Keys are scoped to the **API key** that sent them: the same `Idempotency-Key` used with a live and a test key are two different keys.
## Example [#example]
```bash
# First call: runs the decision.
curl -i -X POST https://api.dcision.io/v1/decisions/spam-detection \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: msg-20261005-0042" \
-d '{ "state": "Congratulations! You won a $1000 gift card, click here to claim now." }'
# Same call again: replayed.
# HTTP/1.1 200 OK
# Idempotent-Replayed: true
```
Sending the same call again while the first one is still running returns `409` with `Retry-After: 1`; retry after a second to get its response:
```json title="409 Conflict — still running"
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "A request with this Idempotency-Key is still running. Retry in a moment to get its response.",
"request_id": "req_Hn5Wq2Lk8Tz1Vb4Xc7Pm"
}
}
```
Sending a different state with `msg-20261005-0042` within 24 hours returns:
```json title="409 Conflict"
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "This Idempotency-Key was already used with a different request body.",
"request_id": "req_Fw2Lk9Tq7Mz1Vb6Xc3Pn"
}
}
```
## Good to know [#good-to-know]
* **Replays are served first.** A replay is returned even if the decision has since been redeployed or disabled: you get exactly what the first call returned, including its `version`.
* **Replays count toward the [rate limit](https://docs.dcision.io/docs/api/rate-limits)**, like any authenticated request.
* **Concurrent duplicates are safe.** The key is reserved before the decision runs, so two requests sent *at the same time* with the same key never both run — or bill, or deliver: the second one gets `409` with `Retry-After: 1`, and its retry gets the stored response. The [SDKs](https://docs.dcision.io/docs/sdks) wait and retry on their own.
* **Errors free the key.** The key is checked right after authentication, the rate limit and the body: any later failure — an unknown or disabled decision, the quota, an invalid state, an engine error — releases it, and the next attempt starts over.
* **Stored responses contain the answers.** They are kept for 24 hours whatever the decision's `storeOutput` setting; the state itself isn't stored for idempotency.
* **The SDKs and the CLI do it for you.** The [SDKs](https://docs.dcision.io/docs/sdks) send an `Idempotency-Key` with every `decide` call and reuse it on their retries; `dcision decide` generates one when you don't pass one and retries once on network errors — see [CLI](https://docs.dcision.io/docs/cli).
See [Idempotent retries](https://docs.dcision.io/docs/guides/idempotent-retries) for a complete client in JavaScript and Python.
---
# Limits
> Every size and count limit in one place — request body, state and token budget, decision schema, destinations and deliveries, identifiers, workspace, team and sign-in limits.
Source: https://docs.dcision.io/docs/api/limits
## API requests [#api-requests]
| Limit | Value | When exceeded |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Request body | 128 KB | `413 PAYLOAD_TOO_LARGE` — "Request body is too large." |
| State, serialized | 128 KB **and** about 32,000 tokens (characters ÷ 4, so about 128,000 characters of JSON) | `422 INVALID_STATE` — "Field state is too large (…)" |
| Token budget per call | about 32,000 tokens for the state plus the longest question, 64,000 for the state plus all questions | `422 INVALID_STATE` — "The state plus … is too large …" |
| Items in a list state | 1 to 500 | `422 INVALID_STATE` — "Field state has too many items (…)" |
| Requests per minute | 60 / 300 / 2,000 / 10,000 by plan, per workspace — decisions and reads (`GET /v1/decisions`, `/v1/me`, `/v1/executions`) counted separately | `429 RATE_LIMITED` — see [Rate limits](https://docs.dcision.io/docs/api/rate-limits) |
| Included volume | 1M a month on Genesis, more on paid plans; past it, decisions are paid with prepaid credits, up to the optional spend cap per billing cycle | `402 CREDITS_EXHAUSTED` without credits, `402 SPEND_CAP_REACHED` at the spend cap — `402 QUOTA_EXCEEDED` on a plan with a hard cap. See [Plans](https://docs.dcision.io/docs/plans-and-billing#credits) |
| Engine deadline | the decision's `timeoutMs`: 5,000 ms by default, 500 to 30,000, retries included | `504 ENGINE_TIMEOUT` |
| `GET /v1/executions` page size | 1 to 100, default 20 | `400 INVALID_REQUEST` |
| `Idempotency-Key` | 1–128 characters from `A–Z a–z 0–9 . _ : -` | `400 INVALID_REQUEST` |
| `X-Request-ID` | 1–128 characters from the same set | Replaced by a generated `req_…` ID (no error) |
| Idempotent replays | 24 hours | Treated as a new request |
Tokens are estimated as characters ÷ 4 of the serialized JSON. The token budget is Jev's: the questions count with their instructions, options, levels and criteria.
## Decision schema [#decision-schema]
| Item | Limit |
| ------------------------------------------------------ | ------------------------------- |
| Questions per decision | 1 to 64 |
| Policy rules per decision | up to 50 |
| Extra AND conditions per rule | up to 5 |
| Composites per decision | up to 20 |
| Terms per composite | 1 to 20 |
| Composite term weight | −100 to 100, not 0 |
| State fields (object state) | up to 50 |
| Items in a list state | 1 to 500 |
| Options per choice question | 2 to 254, plus `other` |
| Levels per score question | 2 to 10 |
| Instructions | 1 to 8,000 characters |
| `context` | up to 8,000 characters |
| Option description, score level, probability criterion | up to 2,000 characters |
| State field description | up to 255 characters |
| Policy string value, structured level `label` | up to 255 characters |
| Composite description | up to 500 characters |
| Keys (fields, questions, composites, option values) | snake_case, up to 48 characters |
| `minConfidence` | 0 to 1 |
| `timeoutMs` | 500 to 30,000 |
Instructions, option descriptions, score levels and criteria can be text or JSON; JSON is measured on its serialized length. See [Decision schema](https://docs.dcision.io/docs/concepts/decision-schema).
## Decisions [#decisions]
| Item | Limit |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| Name | 1 to 80 characters |
| Description | up to 500 characters |
| Slug | up to 64 characters: lowercase letters, digits and single dashes; locked after the first deploy |
| Decisions per workspace | Genesis 2, Developer 10, Growth and Enterprise unlimited (`402 PLAN_LIMIT_REACHED`) |
## Workspace [#workspace]
| Item | Limit |
| ----------------------- | ------------------------------------------------------------------------------------------ |
| Active API keys | 50 per workspace (`409 CONFLICT` — revoke an unused key first) |
| API key name | 1 to 80 characters |
| Provider (BYOK) key | 8 to 512 characters |
| Execution log retention | Genesis 7, Developer 14, Growth 30, Enterprise 365 days — or less, per workspace setting |
| Playground | 30 runs per minute and 2,000 per day |
| Sign-in codes | 6 digits, valid 10 minutes, 5 attempts, 3 codes per e-mail every 10 minutes and 10 per day |
| Password sign-in | 5 wrong attempts per e-mail, then a 15-minute wait |
| Password | 10 to 128 characters, letters and numbers |
## Team [#team]
| Item | Limit |
| ------------------- | --------------------------------------------------------------------------- |
| Workspaces you own | 10 (`409 CONFLICT`) |
| Invitations | valid 7 days; 30 per hour per workspace and per person (`429 RATE_LIMITED`) |
| Pending invitations | 50 per workspace (`409 CONFLICT`) |
See [Team, roles and account](https://docs.dcision.io/docs/team-and-roles).
## Destinations [#destinations]
| Limit | Value |
| ------------------------------------------- | ---------------------------------- |
| Destinations per decision | 20 |
| Destinations that fire per execution | 10 |
| LLM answers and `sync` agents per execution | 3 |
| Conditions per destination | 5 |
| Params per destination | 30 |
| Fixed param value | up to 500 characters |
| Headers per destination | 20 |
| Header value | up to 2,048 characters |
| URL | up to 2,048 characters |
| `http` body template | up to 10,000 characters |
| Destination description | up to 2,000 characters |
| Reply text | up to 4,000 characters |
| Reply buttons | up to 10, up to 80 characters each |
| LLM prompt, LLM input, agent instructions | up to 8,000 characters each |
| Agent tools | up to 50 |
| LLM and `sync` agent timeout | 1,000 to 25,000 ms, default 20,000 |
| LLM `maxTokens` | 16 to 2,048, default 512 |
| LLM `temperature` | 0 to 2, default 0.3 |
| Agent answer returned in `response` | up to 16,000 characters |
| Delivery attempts | 6, over about 7 hours |
| Delivery attempt timeout | 10 seconds |
| Deliveries per workspace | 600 per minute |
| Delivery retention | 30 days |
| Workspace secrets | 50, values up to 4,096 characters |
Beyond these, a destination that matches is skipped with `"reason": "limit"`, and an invalid destination makes the schema invalid (`422 INVALID_SCHEMA`). See [Destination security and limits](https://docs.dcision.io/docs/destinations/security-and-limits).
---
# SDKs overview
> The official TypeScript and Python SDKs — what they add over plain HTTPS, how they compare, and how to build them from source until they are published on npm and PyPI.
Source: https://docs.dcision.io/docs/sdks
Two official SDKs wrap the [public API](https://docs.dcision.io/docs/api): **`@dcision/sdk`** for TypeScript and JavaScript, and **`dcision`** for Python. They run decisions with safe retries, dispatch [function destinations](https://docs.dcision.io/docs/destinations/functions), verify [webhook signatures](https://docs.dcision.io/docs/destinations/webhooks#verify-the-signature) and read your account and executions — with the same behavior in both languages.
`@dcision/sdk` isn't on npm and `dcision` isn't on PyPI yet. Build them from a checkout of the Dcision repository, as below. Until this page says they are published, a package with these names on npm or PyPI doesn't come from Dcision: don't install it.
```bash title="Build from source"
# In a checkout of the Dcision repository — Node.js 22 and pnpm 10 for the workspace
pnpm install
pnpm --filter @dcision/sdk build # TypeScript: builds packages/sdk/dist
pip install ./packages/sdk-python # Python: installs into the active environment
```
Then add the TypeScript build to your project with `npm install /path/to/dcision/packages/sdk` — see [TypeScript SDK](https://docs.dcision.io/docs/sdks/typescript#install) and [Python SDK](https://docs.dcision.io/docs/sdks/python#install).
## What they add [#what-they-add]
* **Retries that never run a decision twice.** Every `decide` call sends an `Idempotency-Key` — yours, or a new UUID reused by all its retries — and retries network errors, timeouts, `429`, `502`, `503` and `504`, honoring `Retry-After`. A second request that finds the first still running waits for it instead of failing.
* **Function destinations.** Register handlers by name; the SDK calls them in order after the decision.
* **Webhook verification.** One call checks `Dcision-Signature` in constant time, the timestamp and the event.
* **Typed responses and one error type.** Every API field is typed, and every failure — an API error, a network error, a handler that throws, a bad signature — is a `DcisionError` with a stable `code`.
* **Safe defaults.** The API key is validated locally and never printed; requests use `https` and never follow redirects, so the key can't reach another host.
## Compared [#compared]
| | TypeScript | Python |
| ----------------------- | ------------------------------------------------------- | --------------------------------------------------- |
| Package | `@dcision/sdk` (`packages/sdk`) | `dcision` (`packages/sdk-python`) |
| Runtimes | Node.js 20+, Bun, Deno, Cloudflare Workers, Vercel Edge | Python 3.9+ |
| Dependencies | None — `fetch` and Web Crypto | None — the standard library |
| Client | Asynchronous (promises) | Synchronous; use `asyncio.to_thread` in async code |
| Run a decision | `dcision.decide(slug, state, options)` | `client.decide(slug, state, ...)` |
| Function destinations | `functions`, `decision.dispatch()` | `functions=`, `decision.dispatch()` |
| Webhooks | `verifyWebhook()`, `verifySignature()` | `verify_webhook()`, `verify_signature()` |
| Account and executions | `me()`, `executions.list()`, `executions.iterate()` | `me()`, `executions.list()`, `executions.iterate()` |
| Timeout of each attempt | 30 seconds | 30 seconds |
| Retries | 2 (3 attempts) | 2 (3 attempts) |
## Without an SDK [#without-an-sdk]
The API is a single HTTPS `POST` with JSON, so any language works: see the cURL, JavaScript and Python examples in [Run a decision](https://docs.dcision.io/docs/api/run-decision) and a complete client with retries in [Idempotent retries](https://docs.dcision.io/docs/guides/idempotent-retries). The [CLI](https://docs.dcision.io/docs/cli) covers the terminal and CI — it is built from source too.
---
# TypeScript SDK
> @dcision/sdk for Node.js, Bun, Deno and edge runtimes — build from source, decide, destinations and functions, errors, idempotent retries, executions, me() and webhook verification.
Source: https://docs.dcision.io/docs/sdks/typescript
`@dcision/sdk` is the official JavaScript and TypeScript SDK. It has **no dependencies** — it only uses `fetch` and Web Crypto — ships ESM, CommonJS and types, and runs on Node.js 20+, Bun, Deno and edge runtimes.
## Install [#install]
`@dcision/sdk` isn't published on npm. Build it from a checkout of the Dcision repository; until this page says it is published, a package with this name on npm doesn't come from Dcision.
```bash
# 1. In a checkout of the Dcision repository (Node.js 22 and pnpm 10)
pnpm install
pnpm --filter @dcision/sdk build
# 2. In your project
npm install /path/to/dcision/packages/sdk
```
`npm install` with a folder links it into your project; pnpm, yarn and bun accept the same path. To copy the package instead — into a Docker image, another machine — make a tarball and install that:
```bash
cd /path/to/dcision/packages/sdk && npm pack # writes dcision-sdk-0.1.0.tgz
npm install ./dcision-sdk-0.1.0.tgz # in your project
```
## Quickstart [#quickstart]
Create an API key in **API Keys** and set it as `DCISION_API_KEY` in your server's environment.
```ts
import { Dcision } from "@dcision/sdk";
const dcision = new Dcision(); // reads DCISION_API_KEY
const decision = await dcision.decide("lead-qualification", {
message: "We need pricing for 500 users and want to start next month.",
company_size: 500,
});
decision.result.route; // "sales"
decision.confidence.route; // 0.85
decision.action; // "continue" | "block" | "escalate" | "fallback"
decision.destinations; // what happens next: replies, queued deliveries, functions…
```
CommonJS works the same way: `const { Dcision } = require("@dcision/sdk")`.
## Configuration [#configuration]
```ts
const dcision = new Dcision({
apiKey: process.env.DCISION_API_KEY, // default: the DCISION_API_KEY environment variable
timeoutMs: 30_000,
maxRetries: 2,
userAgent: "acme-crm/2.1",
});
```
| Option | Default | Description |
| ------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apiKey` | `DCISION_API_KEY` | `dcs_live_…` or `dcs_test_…`. A missing key, or one that doesn't start with `dcs_`, throws `DcisionError` `INVALID_API_KEY` at once — nothing is sent. |
| `baseUrl` | `https://api.dcision.io` | Must use `https` — plain `http` only for an API on a loopback address. A path prefix is fine; credentials, a query string or a fragment are refused. |
| `timeoutMs` | `30000` | Timeout of **each attempt**, in milliseconds. |
| `maxRetries` | `2` | Retries after a network error, a timeout, `429`, `502`, `503` or `504`. |
| `fetch` | the global `fetch` | A custom implementation: `(url, init) => Promise`. |
| `userAgent` | — | Your app's identifier, prepended to the SDK's: `acme-crm/2.1 dcision-sdk-js/0.1.0`. |
Create one client and share it: it keeps no per-request state.
## Run a decision [#run-a-decision]
```ts
const decision = await dcision.decide(slug, state, options);
```
`state` is what the decision evaluates — a string, an object or an array, as the decision declares. The slug is checked locally (lowercase letters, digits and single dashes) before anything is sent.
| Option | Description |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `idempotencyKey` | The `Idempotency-Key`: 1 to 128 characters from `A–Z a–z 0–9 . _ : -`. Default: a new UUID per call, reused by all its retries. |
| `includeProbabilities` | Adds `probabilities`, the full distribution of every question (`?include=probabilities`). |
| `functions` | Handlers for [function destinations](#function-destinations). |
| `onMissingFunction` | `"throw"` (default) or `"ignore"`. |
| `signal` | An `AbortSignal`: aborting stops the request and its retries, and `decide` rejects with `signal.reason`. |
`decide` resolves to a `Decision`: every field of the [API response](https://docs.dcision.io/docs/api/run-decision#response) — `decision_id`, `execution_id`, `schema`, `version`, `result`, `confidence`, `scores`, `composites`, `action`, `action_reason`, `probabilities`, `metrics`, `destinations` — plus `functionResults` and `dispatch()`. `destinations` is always an array: `[]` when the decision has none or none fired.
A decision with `onEngineError: "fallback"` answers with its fallback action when the engine fails: then `action_reason.type` is `"engine_error"` and `result` and `confidence` are empty objects.
## Destinations [#destinations]
`decision.destinations` holds the [destination entries](https://docs.dcision.io/docs/api/run-decision#destinations) of the response. `Destination` is a union you narrow on `type` and `status`:
```ts
for (const destination of decision.destinations) {
if (destination.type === "reply") chat.send(destination.text, destination.buttons);
else if (destination.type === "llm" && destination.status === "completed") chat.send(destination.text);
else if (destination.type === "agent" && destination.status === "completed") chat.send(destination.reply ?? "");
else if (destination.status === "failed") log.warn(`${destination.key}: ${destination.error}`);
else if (destination.status === "queued") log.info(`${destination.key} queued as ${destination.delivery_id}`);
else if (destination.status === "skipped") log.info(`${destination.key} skipped: ${destination.reason}`);
}
```
## Function destinations [#function-destinations]
A [function destination](https://docs.dcision.io/docs/destinations/functions) names a function and its params. Pass the handlers in `functions` and the SDK calls them after the decision:
```ts
const decision = await dcision.decide("lead-qualification", { message, email }, {
functions: {
assignToSales: async (params, decision) => crm.assign(String(params.email), decision.result.route),
notifySlack: (params) => slack.post(String(params.text)),
},
});
decision.functionResults;
// [{ key: "assign_owner", function: "assignToSales", result: }]
```
* Handlers are called as `functions[name](params, decision)`, **in the order of `destinations`, one at a time**: an async handler is awaited before the next one starts.
* Only function entries that fired are called — not skipped ones, and not the other types.
* **Missing handler:** every handler is looked up before the first one runs. If one is missing, `decide` throws `DcisionError` `FUNCTION_NOT_REGISTERED` and no handler runs — unless you pass `onMissingFunction: "ignore"`.
* **A handler throws:** the next handlers aren't called and `decide` throws `DcisionError` `FUNCTION_FAILED`, with `cause` (what the handler threw), `decision` and `functionResults` (the handlers that completed). The decision already ran — and was billed.
* Handlers run **once per `decide` call**, after the final answer: retries inside the call never dispatch twice. Calling `decide` again with the same `idempotencyKey` replays the stored answer and dispatches its functions again, so make handlers idempotent — for example keyed on `decision.execution_id`.
Dispatch later, or somewhere else, from a stored response:
```ts
import { Decision } from "@dcision/sdk";
const decision = await dcision.decide("lead-qualification", state); // no functions: nothing is called
await queue.push(JSON.stringify(decision));
// in a worker
const results = await new Decision(JSON.parse(job.payload)).dispatch(handlers, { onMissingFunction: "ignore" });
```
## Errors [#errors]
Everything the SDK throws for an API answer, a network failure, a handler or a signature is a `DcisionError`:
```ts
import { DcisionError } from "@dcision/sdk";
try {
await dcision.decide("lead-qualification", state);
} catch (error) {
if (error instanceof DcisionError && error.code === "INVALID_STATE") return reportBadInput(error.message);
throw error;
}
```
| Field | Description |
| ----------------------------- | -------------------------------------------------------------------------------------------------------- |
| `code` | A stable code: switch on it, not on `message`. |
| `message` | A human-readable explanation. |
| `status` | The HTTP status; `undefined` when there was no response (network error, timeout) or the check was local. |
| `requestId` | The request's `request_id` (`X-Request-ID`): quote it when you contact support. |
| `details` | Extra data for some codes — see [Errors](https://docs.dcision.io/docs/api/errors#details). |
| `retryAfter` | Seconds from the `Retry-After` header, when there was one. |
| `idempotencyKey` | The `Idempotency-Key` that `decide` sent: reuse it to retry safely later. |
| `cause` | The underlying error: a network failure, a handler's exception. |
| `decision`, `functionResults` | `FUNCTION_NOT_REGISTERED` and `FUNCTION_FAILED` only. |
The API's codes are listed in [Errors](https://docs.dcision.io/docs/api/errors). The SDK adds:
| Code | When |
| ------------------------- | ------------------------------------------------------------------------------------- |
| `NETWORK_ERROR` | No answer from the API: DNS failure, connection refused or reset, TLS error. Retried. |
| `TIMEOUT` | An attempt took longer than `timeoutMs`. Retried. |
| `FUNCTION_NOT_REGISTERED` | A function destination has no handler in `functions`. |
| `FUNCTION_FAILED` | A handler threw. |
| `INVALID_SIGNATURE` | A webhook failed verification. |
Local checks reuse the API's codes with `status` undefined: `INVALID_API_KEY` for a missing or malformed key, `INVALID_REQUEST` for a bad slug, `idempotencyKey` or a missing state. An answer that doesn't come from the API — an HTML page from a proxy, a redirect — keeps its HTTP status, with a fallback code: `INVALID_API_KEY` (401), `FORBIDDEN` (403), `NOT_FOUND` (404), `CONFLICT` (409), `PAYLOAD_TOO_LARGE` (413), `RATE_LIMITED` (429), `INVALID_REQUEST` (other `4xx`) or `INTERNAL_ERROR`. Redirects are never followed.
## Retries and idempotency [#retries-and-idempotency]
`decide` sends an `Idempotency-Key`, generated once per call and **reused by every retry**. If an attempt ran the decision but its answer was lost, the retry gets the stored answer: **retries never run, or bill, a decision twice**.
| Retried | Not retried |
| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Network errors and timeouts | `500 INTERNAL_ERROR` |
| `409 IDEMPOTENCY_CONFLICT` **with** `Retry-After`: the first request with the key is still running | `409` without `Retry-After` — a different request reused the key — and the other `4xx` |
| `429 RATE_LIMITED` | `502 ENGINE_AUTH_FAILED` and `ENGINE_INVALID_REQUEST`: they fail the same way every time |
| Other `502` errors, `503` and `504` | Function and signature errors |
* Up to `maxRetries` retries: 2 by default, so 3 attempts.
* The SDK waits for `Retry-After` when the API sends it, up to 30 s. A longer `Retry-After` isn't waited for: the error is thrown at once, with `retryAfter` set, so you can schedule the retry.
* Without `Retry-After`, it waits 0.5 s, 1 s, 2 s… — doubling, at most 8 s — plus up to 25% of random jitter.
* `me()` and `executions` are retried the same way.
The `409` case is what makes a timeout safe: if an attempt times out while the decision is still running, the retry finds the key reserved, waits one second (`Retry-After: 1`) and tries again until it gets the stored answer — or runs out of retries and throws `IDEMPOTENCY_CONFLICT` with the key in `error.idempotencyKey`, to try again later.
Pass your own key to make retries safe **across processes or restarts**, for example the ID of what you decide about:
```ts
await dcision.decide("support-routing", ticket, { idempotencyKey: `ticket-${ticket.id}` });
```
`timeoutMs` applies to each attempt; for a deadline on the whole call, pass a signal: `{ signal: AbortSignal.timeout(15_000) }`. Keep `timeoutMs` above the decision's own `timeoutMs` (5 s by default, 30 s at most) **plus** the timeout of its slowest [LLM or `sync` agent destination](https://docs.dcision.io/docs/destinations/llm#latency), so an attempt has really finished on Dcision's side before it is retried.
## Account and executions [#account-and-executions]
```ts
const me = await dcision.me();
me.plan.rate_limit_per_minute;
me.usage.used / me.usage.included;
// one page, newest first
const page = await dcision.executions.list({ decision: "lead-qualification", status: "error", limit: 100 });
page.data; // the runs: answers, action, metrics — never the inputs
page.next_cursor; // pass it as `cursor` for the next page; null on the last one
// every page
for await (const run of dcision.executions.iterate({ decision: "lead-qualification", limit: 100 })) {
console.log(run.created_at, run.result?.route, run.action);
}
```
`iterate()` fetches the next page only when you get to it, so `break` stops it. Reads are never billed and have their own rate-limit window — see [Get account and usage](https://docs.dcision.io/docs/api/me) and [List executions](https://docs.dcision.io/docs/api/executions).
## Webhooks [#webhooks]
```ts
import { DcisionError, verifyWebhook } from "@dcision/sdk";
const event = await verifyWebhook(rawBody, signatureHeader, process.env.DCISION_WEBHOOK_SECRET!);
event.type; // "decision.completed"
event.id; // "dlv_…": the same on every delivery attempt
event.data.result.route; // "sales"
event.data.params; // the params mapped in the destination (the state itself is never sent)
```
`verifyWebhook(body, header, secret, { toleranceSec = 300, now })`:
* `body` — the **raw** body, as a `string`, a `Uint8Array` or `Buffer`, or an `ArrayBuffer`;
* `header` — the `Dcision-Signature` header;
* `secret` — the signing secret (`whsec_…`, in **Settings → Destinations**), or an array of secrets while you rotate it;
* `toleranceSec` — how far the signature's timestamp may be from `now`;
* `now` — Unix seconds or a `Date`, for tests.
It throws `DcisionError` `INVALID_SIGNATURE` when the header is missing or malformed, no signature matches (compared in constant time), the timestamp is outside the tolerance or the body isn't a JSON event — answer `400`. Misuse, such as no secret or an already parsed body, throws a `TypeError` instead — answer `500`, so Dcision keeps retrying until the fix is deployed.
`verifySignature(body, header, secret, options)` does the same checks without parsing the body: use it for [API requests](https://docs.dcision.io/docs/destinations/http-requests#the-signature) and [agents](https://docs.dcision.io/docs/destinations/agents), whose bodies aren't events.
Full receivers for Express and Next.js are in [Webhooks](https://docs.dcision.io/docs/destinations/webhooks#express).
## TypeScript [#typescript]
Type the answers of a decision with a generic — interfaces work too:
```ts
interface LeadResult {
route: "sales" | "sdr" | "nurture" | "spam" | "other";
purchase_intent: number;
}
const decision = await dcision.decide("lead-qualification", state, {
functions: {
// handlers can declare the params they expect
assignToSales: async (params: { email: string }, decision) => crm.assign(params.email, decision.result.route),
},
});
decision.result.route; // "sales" | "sdr" | "nurture" | "spam" | "other"
const event = await verifyWebhook(body, header, secret);
event.data.result.route;
```
The package exports `Dcision`, `Decision`, `DcisionError`, `verifyWebhook`, `verifySignature` and `VERSION`, and types such as `DecisionResponse`, `Destination`, `QueuedDestination`, `SkippedDestination`, `FunctionDestination`, `WebhookEvent`, `Me`, `Execution`, `ExecutionPage` and `DcisionErrorCode`.
## Runtime support [#runtime-support]
| Runtime | Notes |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node.js 20+ | ESM (`import`) and CommonJS (`require`). |
| Bun | Same as Node.js. |
| Deno | Import the build: `import { Dcision } from "/path/to/dcision/packages/sdk/dist/esm/index.js"`. Allow the network (`--allow-net=api.dcision.io`) and, to read `DCISION_API_KEY`, `--allow-env=DCISION_API_KEY` — or pass `apiKey`. |
| Cloudflare Workers, Vercel Edge | Pass `apiKey` from your bindings or environment. |
| Browsers | Not supported: an API key in a browser is public. Call Dcision from your server. |
## Security notes [#security-notes]
* **The API key stays private.** It lives in a private field — not printed by `console.log`, not in `JSON.stringify` — and never appears in an error message. A value that isn't a Dcision key is refused before any request.
* **Transport.** `https` only, and redirects are never followed, so the key never reaches another host.
* **Paths.** The slug is validated and encoded: nothing else can end up in the URL path.
* **Webhooks.** HMAC-SHA256 compared in constant time, timestamps outside the tolerance rejected, verification over the raw bytes only.
---
# Python SDK
> The dcision package for Python 3.9+ — build from source, decide, destinations and functions, errors, idempotent retries, executions, me() and webhook verification with Flask or FastAPI.
Source: https://docs.dcision.io/docs/sdks/python
`dcision` is the official Python SDK. It needs **Python 3.9+** and nothing else — only the standard library (`urllib`, `json`, `hmac`) — and it is fully typed (`py.typed`, `mypy --strict` clean).
## Install [#install]
`dcision` isn't published on PyPI. Install it from a checkout of the Dcision repository; until this page says it is published, a package with this name on PyPI doesn't come from Dcision.
```bash
# From a checkout of the Dcision repository, in your project's environment
pip install ./packages/sdk-python
```
`uv pip install ./packages/sdk-python` works the same way. To ship it elsewhere, build a wheel — `python -m build packages/sdk-python` writes it to `packages/sdk-python/dist` — and install that file, or point your requirements at the folder: `dcision @ file:///path/to/dcision/packages/sdk-python`.
## Quickstart [#quickstart]
Create an API key in **API Keys** and set it as `DCISION_API_KEY` in your server's environment.
```python
from dcision import Dcision
client = Dcision() # reads DCISION_API_KEY
decision = client.decide(
"lead-qualification",
{"message": "We need pricing for 500 users and want to start next month.", "company_size": 500},
)
decision.result["route"] # "sales"
decision.confidence["route"] # 0.85
decision.action # "continue" | "block" | "escalate" | "fallback"
decision.destinations # what happens next: replies, queued deliveries, functions…
```
## Configuration [#configuration]
```python
client = Dcision(
api_key=os.environ["DCISION_API_KEY"], # default: the DCISION_API_KEY environment variable
base_url="https://api.dcision.io",
timeout=30,
max_retries=2,
user_agent="acme-crm/2.1",
)
```
| Argument | Default | Description |
| ------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `api_key` | `DCISION_API_KEY` | `dcs_live_…` or `dcs_test_…`. A missing key, or one that doesn't start with `dcs_`, raises `DcisionError` `INVALID_API_KEY` at once — nothing is sent. |
| `base_url` | `https://api.dcision.io` | Must use `https` — plain `http` only for an API on a loopback address. A path prefix is fine. |
| `timeout` | `30` | Seconds to wait on each attempt — for the connection and for each read. |
| `max_retries` | `2` | Retries after a network error, a timeout, `429`, `502`, `503` or `504`. |
| `user_agent` | — | Keyword-only. Your app's identifier, prepended to the SDK's: `acme-crm/2.1 dcision-sdk-python/0.1.0`. |
Create one client and share it — across threads too: it keeps no per-request state. The `HTTPS_PROXY` environment variable is honored. Invalid arguments raise `ValueError` or `TypeError`.
## Run a decision [#run-a-decision]
```python
decision = client.decide(slug, state, idempotency_key=None, include_probabilities=False,
functions=None, on_missing_function="raise")
```
`state` is a `str`, a `dict` or a `list`, as the decision declares. The slug is checked locally (lowercase letters, digits and single dashes) before anything is sent.
| Argument | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `idempotency_key` | The `Idempotency-Key`: 1 to 128 characters from `A–Z a–z 0–9 . _ : -`. Default: a new UUID per call, reused by all its retries. |
| `include_probabilities` | Adds `probabilities`, the full distribution of every question. |
| `functions` | Handlers for [function destinations](#function-destinations), by function name. |
| `on_missing_function` | `"raise"` (default) or `"ignore"`. |
`decide()` returns a `Decision`. Every field of the [API response](https://docs.dcision.io/docs/api/run-decision#response) is an attribute — `decision_id`, `execution_id`, `schema` (the slug), `version`, `result`, `confidence`, `scores`, `composites`, `action`, `action_reason`, `probabilities`, `metrics` and `destinations` (always a list: `[]` when none fired). `decision.raw` is the response exactly as the API sent it, and `decision.function_results` lists the handlers that ran.
A decision with `onEngineError: "fallback"` answers with its fallback action when the engine fails: then `decision.action_reason["type"]` is `"engine_error"` and `result` and `confidence` are empty.
## Destinations [#destinations]
Each entry of `decision.destinations` is a `dict` — see [the entries](https://docs.dcision.io/docs/api/run-decision#destinations):
```python
for destination in decision.destinations:
status = destination.get("status")
if destination["type"] == "reply":
chat.send(destination["text"], destination["buttons"])
elif destination["type"] in ("llm", "agent") and status == "completed":
chat.send(destination.get("text") or destination.get("reply") or "")
elif status == "failed":
log.warning("%s: %s", destination["key"], destination["error"])
elif status == "queued":
log.info("%s queued as %s", destination["key"], destination["delivery_id"])
elif status == "skipped":
log.info("%s skipped: %s", destination["key"], destination["reason"])
```
## Function destinations [#function-destinations]
A [function destination](https://docs.dcision.io/docs/destinations/functions) names a function and its params. Pass the handlers in `functions` and the SDK calls them after the decision:
```python
def assign_to_sales(params, decision):
return crm.assign(params["email"], decision.result["route"])
decision = client.decide(
"lead-qualification",
{"message": message, "email": email},
functions={"assignToSales": assign_to_sales, "notifySlack": lambda params, decision: slack.post(params["text"])},
)
decision.function_results
# [FunctionResult(key="assign_owner", function="assignToSales", result=)]
```
* Handlers are called as `functions[name](params, decision)`, **in the order of `destinations`, one at a time**. Only function entries that fired are called.
* **Missing handler:** every handler is looked up before the first one runs. If one is missing, `decide()` raises `DcisionError` `FUNCTION_NOT_REGISTERED` and no handler runs — unless you pass `on_missing_function="ignore"`.
* **A handler raises:** the next handlers aren't called and `decide()` raises `DcisionError` `FUNCTION_FAILED`, with `cause` (also `__cause__`), `decision` and `function_results` (the handlers that completed). The decision already ran — and was billed.
* Handlers run **once per `decide()` call**, after the final answer. Calling `decide()` again with the same `idempotency_key` replays the stored answer and dispatches its functions again, so make handlers idempotent — for example keyed on `decision.execution_id`.
* Function names are dictionary keys, so any name configured in Dcision works, `$` included.
* The client is synchronous: an `async def` handler raises `FUNCTION_FAILED` instead of being skipped silently.
Dispatch later, or in another process, with `dispatch()`:
```python
decision = client.decide("lead-qualification", state) # no functions: nothing is called
queue.push(json.dumps(decision.raw))
# in a worker
from dcision import Decision
results = Decision(json.loads(job.payload)).dispatch(handlers, on_missing_function="ignore")
```
## Errors [#errors]
Everything the SDK raises for an API answer, a network failure, a handler or a signature is a `DcisionError`:
```python
from dcision import DcisionError
try:
client.decide("lead-qualification", state)
except DcisionError as error:
if error.code == "INVALID_STATE":
report_bad_input(error.message)
else:
raise
```
| Attribute | Description |
| ------------------------------ | --------------------------------------------------------------------------------------------------- |
| `code` | A stable code: compare it, not `message`. |
| `message` | A human-readable explanation (also `str(error)`). |
| `status` | The HTTP status; `None` when there was no response (network error, timeout) or the check was local. |
| `request_id` | The request's `request_id` (`X-Request-ID`): quote it when you contact support. |
| `details` | Extra data for some codes — see [Errors](https://docs.dcision.io/docs/api/errors#details). |
| `retry_after` | Seconds from the `Retry-After` header, when there was one. |
| `idempotency_key` | The `Idempotency-Key` that `decide()` sent: reuse it to retry safely later. |
| `cause` | The underlying exception (also `__cause__`). |
| `decision`, `function_results` | `FUNCTION_NOT_REGISTERED` and `FUNCTION_FAILED` only. |
The API's codes are listed in [Errors](https://docs.dcision.io/docs/api/errors). The SDK adds `NETWORK_ERROR` and `TIMEOUT` (both retried), `FUNCTION_NOT_REGISTERED`, `FUNCTION_FAILED` and `INVALID_SIGNATURE`. Local checks reuse the API's codes with `status=None`: `INVALID_API_KEY` for a missing or malformed key, `INVALID_REQUEST` for a bad slug, `idempotency_key` or a missing state. An answer that doesn't come from the API — an HTML page from a proxy, a redirect — keeps its HTTP status, with a fallback code (`NOT_FOUND`, `PAYLOAD_TOO_LARGE`, `INTERNAL_ERROR`…). `DcisionError` can be pickled for multiprocessing and task queues.
## Retries and idempotency [#retries-and-idempotency]
`decide()` sends an `Idempotency-Key`, generated once per call and **reused by every retry**, so **retries never run, or bill, a decision twice**.
| Retried | Not retried |
| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Network errors and timeouts | `500 INTERNAL_ERROR` |
| `409 IDEMPOTENCY_CONFLICT` **with** `Retry-After`: the first request with the key is still running | `409` without `Retry-After` — a different request reused the key — and the other `4xx` |
| `429 RATE_LIMITED` | `502 ENGINE_AUTH_FAILED` and `ENGINE_INVALID_REQUEST` |
| Other `502` errors, `503` and `504` | Function and signature errors |
* Up to `max_retries` retries: 2 by default, so 3 attempts.
* The SDK waits for `Retry-After` when the API sends it, up to 30 s; a longer `Retry-After` raises at once, with `retry_after` set.
* Without it, the waits are 0.5 s, 1 s, 2 s… — doubling, at most 8 s — plus up to 25% of random jitter.
* `me()` and `executions` are retried the same way.
Pass your own key to make retries safe across processes or restarts:
```python
client.decide("support-routing", ticket, idempotency_key=f"ticket-{ticket['id']}")
```
Keep `timeout` above the decision's own `timeoutMs` (5 s by default, 30 s at most) **plus** the timeout of its slowest [LLM or `sync` agent destination](https://docs.dcision.io/docs/destinations/llm#latency), so an attempt has really finished on Dcision's side before it is retried. If it times out anyway, the retry finds the key reserved, waits for `Retry-After: 1` and gets the stored answer.
## Account and executions [#account-and-executions]
```python
me = client.me()
me["plan"]["rate_limit_per_minute"]
me["usage"]["used"] / me["usage"]["included"]
# one page, newest first
page = client.executions.list(decision="lead-qualification", status="error", limit=100)
page["data"] # the runs: answers, action, metrics — never the inputs
page["next_cursor"] # pass it as cursor= for the next page; None on the last one
# every page
for run in client.executions.iterate(decision="lead-qualification", limit=100):
print(run["created_at"], (run["result"] or {}).get("route"), run["action"])
```
`iterate()` is a generator: it fetches the next page only when you get to it. Reads are never billed and have their own rate-limit window.
## Webhooks [#webhooks]
```python
from dcision import DcisionError, verify_webhook
event = verify_webhook(raw_body, signature_header, os.environ["DCISION_WEBHOOK_SECRET"])
event["type"] # "decision.completed"
event["id"] # "dlv_…": the same on every delivery attempt
event["data"]["result"]["route"] # "sales"
event["data"]["params"] # the params mapped in the destination (the state itself is never sent)
```
`verify_webhook(payload, header, secret, tolerance=300, now=None)`:
* `payload` — the **raw** body: `bytes` (recommended) or `str`;
* `header` — the `Dcision-Signature` header;
* `secret` — the signing secret (`whsec_…`, in **Settings → Destinations**), or a list of secrets while you rotate it;
* `tolerance` — how far, in seconds, the signature's timestamp may be from `now`;
* `now` — Unix time in seconds, for tests.
It raises `DcisionError` `INVALID_SIGNATURE` when the header is missing or malformed, no signature matches (compared with `hmac.compare_digest`), the timestamp is outside the tolerance or the body isn't a JSON event — answer `400`. Misuse, such as no secret or a parsed `dict` as the payload, raises `TypeError` or `ValueError` — let it become a `500`, so Dcision keeps retrying until the fix is deployed.
`verify_signature(...)` does the same checks without parsing the body: use it for [API requests](https://docs.dcision.io/docs/destinations/http-requests#the-signature) and [agents](https://docs.dcision.io/docs/destinations/agents). Flask and FastAPI receivers are in [Webhooks](https://docs.dcision.io/docs/destinations/webhooks#python).
## Typing [#typing]
Responses are plain dicts described by `TypedDict`s in `dcision.types` — `DecisionResponse`, `Destination` and its variants (`ReplyDestination`, `LlmDestination`, `AgentDestination`, `FailedDestination`, `QueuedDestination`, `SkippedDestination`, `FunctionDestination`…), `WebhookEvent`, `Me`, `Execution` and `ExecutionPage`:
```python
from dcision.types import WebhookEvent
def handle(event: WebhookEvent) -> None:
route = event["data"]["result"]["route"]
```
## Async applications [#async-applications]
The client is synchronous. In asyncio code, run it in a thread — `verify_webhook()` does no I/O and can be called directly:
```python
decision = await asyncio.to_thread(client.decide, "lead-qualification", state)
```
## Security notes [#security-notes]
* **The API key stays private.** It is never in `repr()` or in an error message, and a value that isn't a Dcision key is refused before any request.
* **Transport.** `https` only, and redirects are never followed: urllib would forward the `Authorization` header to the new host.
* **Paths.** The slug is validated and encoded: nothing else can end up in the URL path.
* **Webhooks.** HMAC-SHA256 compared in constant time, timestamps outside the tolerance rejected, verification over the raw bytes only.
---
# Use Dcision in Claude Code
> Connect Claude Code to Dcision with the MCP server and the Dcision skill — list, check and run decisions, write and validate decision schemas, and use the CLI and SDKs from your agent.
Source: https://docs.dcision.io/docs/claude-code
Claude Code can work with Dcision in two complementary ways:
* **The MCP server** gives Claude the tools: list your decisions, read their contract, check a state, run a decision, read executions, list templates and validate a schema.
* **The Dcision skill** gives Claude the judgment: how decisions work, how to write a valid [decision schema](https://docs.dcision.io/docs/concepts/decision-schema), which calls are billed and the pitfalls to avoid. It also teaches Claude to use the API with `curl` when the MCP server isn't connected.
Install both. Each takes one command.
## Before you start [#before-you-start]
Create an API key at [app.dcision.io/api-keys](https://app.dcision.io/api-keys) and export it in the shell you start Claude Code from:
```bash
export DCISION_API_KEY="dcs_test_…"
```
Use a `dcs_test_…` key while you experiment. Runs with a test key are real and count as usage, but [destinations](https://docs.dcision.io/docs/destinations) deliver with `livemode: false`, so your CRM and workflows can tell them apart. Switch to a `dcs_live_…` key when an agent runs decisions for production.
## Connect the MCP server [#connect-the-mcp-server]
The Dcision MCP server runs at `https://api.dcision.io/mcp` (Streamable HTTP) and authenticates every request with your API key as a Bearer token.
```bash
claude mcp add --transport http dcision https://api.dcision.io/mcp \
--header "Authorization: Bearer $DCISION_API_KEY" \
--scope user
```
```bash
claude mcp add --transport http dcision https://api.dcision.io/mcp \
--header "Authorization: Bearer $DCISION_API_KEY"
```
The shell expands `$DCISION_API_KEY` when you run the command, and Claude Code stores the header in your user configuration (`~/.claude.json`) — outside the repository.
To share the server with your team, commit a **`.mcp.json`** at the root of the repository instead. Claude Code expands `${DCISION_API_KEY}` from each person's environment, so the file holds no secret:
```json title=".mcp.json"
{
"mcpServers": {
"dcision": {
"type": "http",
"url": "https://api.dcision.io/mcp",
"headers": { "Authorization": "Bearer ${DCISION_API_KEY}" }
}
}
}
```
Don't use `claude mcp add --scope project` with the header above: it writes the expanded key into `.mcp.json`. Write the file by hand with the `${DCISION_API_KEY}` placeholder.
Check the connection with `claude mcp list`, or with `/mcp` inside a session: `dcision` should be **connected** with nine tools. A `401` means the key is missing, malformed or revoked.
## Install the skill [#install-the-skill]
The skill is a single `SKILL.md` file, published with this documentation:
```bash
mkdir -p ~/.claude/skills/dcision
curl -fsSL https://docs.dcision.io/skills/dcision/SKILL.md -o ~/.claude/skills/dcision/SKILL.md
```
```bash
mkdir -p .claude/skills/dcision
curl -fsSL https://docs.dcision.io/skills/dcision/SKILL.md -o .claude/skills/dcision/SKILL.md
```
Claude Code loads it on its own when a request mentions Dcision, a decision slug, a decision schema or routing and classifying with Dcision. Run the same command again to update it. The skill follows the open Agent Skills format, so other agents that read `SKILL.md` files can use it too.
## Try it [#try-it]
Ask in plain language — Claude picks the tools:
* *"Which Dcision decisions do I have, and which are deployed?"*
* *"Show me what `lead-qualification` expects and an example state."*
* *"Run `support-routing` on this ticket and tell me the route and the action."*
* *"Check whether these five payloads are valid states for `lead-qualification` — don't run them."*
* *"Write a decision schema that classifies inbound e-mails into billing, technical and sales, escalates urgent ones, and validate it."*
* *"Start from the `ticket-triage` template and adapt it to our categories."*
* *"How many decisions have we used this month, and how many failed runs did `spam-detection` have today?"*
Creating, editing and deploying decisions stays in the [app](https://app.dcision.io): Claude writes and validates the schema, you paste it in the decision's editor, test it in the [Playground](https://docs.dcision.io/docs/concepts/playground) and deploy it.
## Tools [#tools]
| Tool | Billed | What it does |
| ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
| `get_account` | No | The key's workspace, the key, the plan and this period's usage — like [`GET /v1/me`](https://docs.dcision.io/docs/api/me). |
| `list_decisions` | No | The workspace's decisions: slug, name, status and active version — like [`GET /v1/decisions`](https://docs.dcision.io/docs/api/decisions). |
| `get_decision` | No | One decision's contract: state schema, questions and options, actions and an example state. |
| `check_state` | No | Checks a state against a decision's state schema without running it. |
| `run_decision` | **Yes** | Runs the active version on a state — like [`POST /v1/decisions/{slug}`](https://docs.dcision.io/docs/api/run-decision). |
| `list_executions` | No | Recent runs with answers, action and metrics — never the inputs. |
| `list_templates` | No | The built-in [templates](https://docs.dcision.io/docs/concepts/templates). |
| `get_template` | No | One template's complete decision schema. |
| `validate_schema` | No | Validates a decision schema offline, with the editor's rules. |
`run_decision` is the only tool with a cost, and the only one not annotated as read-only. Claude Code asks before it calls an MCP tool until you allow it: allow the read-only tools freely and keep `run_decision` behind the prompt while you experiment. Inputs, outputs and errors of every tool are in the [MCP reference](https://docs.dcision.io/docs/mcp#tools).
## The CLI and the SDKs from Claude Code [#the-cli-and-the-sdks-from-claude-code]
Claude Code can also drive the [CLI](https://docs.dcision.io/docs/cli) in a terminal — handy for schemas kept in your repository:
```bash
dcision validate decision.json
dcision check-state decision.json --state-file samples/lead-1.json
dcision decide lead-qualification --state-file samples/lead-1.json --json
```
The CLI and the [SDKs](https://docs.dcision.io/docs/sdks) are **not published on npm or PyPI yet**: build them from a checkout of the Dcision repository (`pnpm --filter "@dcision/cli..." build`). The skill tells Claude not to install packages with these names from a registry.
When Claude writes application code that calls Dcision, point it at the [TypeScript SDK](https://docs.dcision.io/docs/sdks/typescript) or the [Python SDK](https://docs.dcision.io/docs/sdks/python) — `dcision.decide("lead-qualification", state)` with safe retries — or at plain HTTPS as in [Run a decision](https://docs.dcision.io/docs/api/run-decision).
## Without the MCP server [#without-the-mcp-server]
The skill works without the MCP server: with `DCISION_API_KEY` in the environment, Claude calls the [REST API](https://docs.dcision.io/docs/api) with `curl`, and the same MCP server over plain HTTP for the tools REST doesn't have — `validate_schema`, `check_state` and the templates. See [Call the server with curl](https://docs.dcision.io/docs/mcp#call-the-server-with-curl).
## Security [#security]
* **Keys stay out of the repository.** Use the `${DCISION_API_KEY}` placeholder in `.mcp.json`, never the key itself, and don't paste keys into the chat.
* **A key can do what the API can do** for its workspace: read its decisions and executions and run decisions. It can't create, edit or deploy decisions, manage members or billing — those need a signed-in user in the app.
* **Runs are billed.** `run_decision` counts like any API call: one decision per run, however many questions. Ask Claude for a sample before it runs a whole dataset, and watch usage with `get_account`.
* **Executions never return inputs.** `list_executions`, like [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions), doesn't return the states you sent.
* **Revoke a key** in **API Keys** if it leaks; the API and the MCP server refuse a revoked key.
## Other clients [#other-clients]
Cursor, VS Code, Claude Desktop, Codex and Gemini CLI connect to the same server — see [MCP server](https://docs.dcision.io/docs/mcp#other-clients).
---
# MCP server
> The Dcision MCP server at https://api.dcision.io/mcp — authentication, the nine tools with their inputs and outputs, errors, and setup for Claude Code, Cursor, VS Code, Claude Desktop, Codex, Gemini CLI and curl.
Source: https://docs.dcision.io/docs/mcp
```text
Endpoint https://api.dcision.io/mcp
Transport Streamable HTTP (stateless, JSON responses)
Auth Authorization: Bearer dcs_live_… | dcs_test_…
```
The [Model Context Protocol](https://modelcontextprotocol.io) server lets AI agents and coding assistants use Dcision: list your decisions, read their contract, check and run states, read executions, browse templates and validate decision schemas. It is part of the Dcision API — same keys, same limits, same billing as the [REST API](https://docs.dcision.io/docs/api).
For a step-by-step setup in Claude Code, with the Dcision skill, see [Use Dcision in Claude Code](https://docs.dcision.io/docs/claude-code).
## Authentication [#authentication]
Send a workspace [API key](https://docs.dcision.io/docs/api/authentication) as a Bearer token on every request. The server keeps no session and stores no credential: each request is authenticated on its own, and the tools act on the key's workspace.
* No key, a malformed key or a revoked key → HTTP `401`, with a `WWW-Authenticate: Bearer` header and a message that says where to create a key.
* Only API keys are accepted — never a session from the app.
* Prefer a `dcs_test_…` key while you experiment: runs are real and count as usage, but destinations deliver with `livemode: false`.
## Limits and billing [#limits-and-billing]
| | |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Every request | Counts in the read [rate-limit window](https://docs.dcision.io/docs/api/rate-limits), shared with `GET /v1/me`, `GET /v1/decisions` and `GET /v1/executions`. Reads are not billed. |
| `run_decision` | Also counts in the decisions window, shared with `POST /v1/decisions/{slug}`. **Billed** like an API call, with the same quota. |
| Requests | `POST /mcp` only. `GET /mcp` and `DELETE /mcp` answer `405`: there are no sessions and no server-sent event stream. |
## Tools [#tools]
Every tool returns its result as JSON — as text in the result's `content`, and as `structuredContent`. Every tool but `run_decision` is annotated `readOnlyHint: true`; `run_decision` is annotated as neither read-only nor idempotent, so clients can ask before they call it.
| Tool | Read-only | Arguments | Returns |
| ----------------- | --------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `get_account` | Yes | — | The key's workspace, the key, the plan and this period's usage — the body of [`GET /v1/me`](https://docs.dcision.io/docs/api/me). |
| `list_decisions` | Yes | — | The workspace's decisions with slug, name, status and active version — the body of [`GET /v1/decisions`](https://docs.dcision.io/docs/api/decisions#list-decisions). |
| `get_decision` | Yes | `slug` | One decision's contract — state schema, questions with their options, actions, fallback action and an example state — the body of [`GET /v1/decisions/{slug}`](https://docs.dcision.io/docs/api/decisions#get-a-decision). |
| `check_state` | Yes | `slug`, `state` | `{ "valid": true }`, or `{ "valid": false, "message" }` with the reason a run would fail with `422 INVALID_STATE`. Checks the active version's state schema and the state's own token limit; nothing runs. A run can still answer `422` when the state plus the longest question exceed the engine's token budget. |
| `run_decision` | **No — billed** | `slug`, `state`, optional `include_probabilities` (boolean) | The decision's response, as [`POST /v1/decisions/{slug}`](https://docs.dcision.io/docs/api/run-decision#response) returns it. |
| `list_executions` | Yes | optional `decision` (slug), `status` (`success` or `error`), `limit` (1–100, default 20), `cursor` | Recent executions, never the inputs — the body of [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions). |
| `list_templates` | Yes | — | `{ "data": [...] }` with each [template](https://docs.dcision.io/docs/concepts/templates)'s `id`, `name`, `description`, `category` and `patterns`. |
| `get_template` | Yes | `id` | The template's summary plus its complete [decision schema](https://docs.dcision.io/docs/concepts/decision-schema) in `schema` and a `sample_state`. |
| `validate_schema` | Yes | `schema` (object) | `{ "valid": true, "schema" }` with the normalized schema — `other` added, defaults filled in — or `{ "valid": false, "issues": [{ "path", "message" }] }`. Same rules as the editor and the API. |
Each tool publishes its exact input schema in `tools/list`: read it there when you build your own client.
### Run decisions [#run-decisions]
The usual flow, which the [Dcision skill](https://docs.dcision.io/docs/claude-code#install-the-skill) teaches agents:
1. `list_decisions` — pick a decision whose `status` is `deployed`.
2. `get_decision` — read `state_schema` and start from `example_state`.
3. `check_state` — fix the state until it passes; it costs nothing.
4. `run_decision` — run it once, then act on `action`.
`run_decision` sends the state as the API does: an object, a string or an array, depending on the decision's [state kind](https://docs.dcision.io/docs/concepts/decision-schema#state-schema). It has no idempotency key: every call that reaches the engine runs and is billed. If a call fails on the network, look for the run with `list_executions` before trying again. For retries that are safe by construction, call the [REST API](https://docs.dcision.io/docs/api/run-decision) with an [`Idempotency-Key`](https://docs.dcision.io/docs/api/idempotency).
### Write decision schemas [#write-decision-schemas]
Creating, editing and deploying decisions happens in the [app](https://app.dcision.io): API keys can't change decisions. An agent can still do most of the work — start from `get_template`, write the schema, run `validate_schema` until it has no issues, and give it to you to paste in the decision's editor, test in the [Playground](https://docs.dcision.io/docs/concepts/playground) and deploy.
## Errors [#errors]
| Where | What you get |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP + JSON-RPC error | `401` without a valid API key (with `WWW-Authenticate: Bearer`), `429` when the read window is used up, `405` for any method but `POST`, `406` without `Accept: application/json, text/event-stream`, `400` for a JSON-RPC batch (send one message per request). The body is a JSON-RPC `error` whose `message` says what to do. A body that isn't JSON at all gets the API's own `400 INVALID_REQUEST` error object. |
| Tool result | `isError: true` and `{ "error": { "code", "message", "details" } }` as text, for everything the API would answer with an error — `DECISION_NOT_FOUND`, `DECISION_NOT_DEPLOYED`, `INVALID_STATE`, `CREDITS_EXHAUSTED`, `SPEND_CAP_REACHED`, `QUOTA_EXCEEDED`, `RATE_LIMITED` on `run_decision`, engine errors — and also for an unknown tool (`NOT_FOUND`) or invalid arguments (`INVALID_REQUEST`, with each issue in `details`). See [Errors](https://docs.dcision.io/docs/api/errors). |
An agent should read the error's `message` — it says what to fix — and not retry a `4xx` unchanged.
## Claude Code [#claude-code]
```bash
claude mcp add --transport http dcision https://api.dcision.io/mcp \
--header "Authorization: Bearer $DCISION_API_KEY" \
--scope user
```
Drop `--scope user` to add it to the current project only, or commit a `.mcp.json` that reads the key from each person's environment:
```json title=".mcp.json"
{
"mcpServers": {
"dcision": {
"type": "http",
"url": "https://api.dcision.io/mcp",
"headers": { "Authorization": "Bearer ${DCISION_API_KEY}" }
}
}
}
```
More in [Use Dcision in Claude Code](https://docs.dcision.io/docs/claude-code).
## Other clients [#other-clients]
Any MCP client that supports remote servers over Streamable HTTP with a custom header can connect.
```json
{
"mcpServers": {
"dcision": {
"url": "https://api.dcision.io/mcp",
"headers": { "Authorization": "Bearer ${env:DCISION_API_KEY}" }
}
}
}
```
```json
{
"servers": {
"dcision": {
"type": "http",
"url": "https://api.dcision.io/mcp",
"headers": { "Authorization": "Bearer ${input:dcision-api-key}" }
}
},
"inputs": [
{ "type": "promptString", "id": "dcision-api-key", "description": "Dcision API key", "password": true }
]
}
```
```json
{
"mcpServers": {
"dcision": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://api.dcision.io/mcp", "--header", "Authorization:${DCISION_AUTH}"],
"env": { "DCISION_AUTH": "Bearer dcs_test_…" }
}
}
}
```
```toml
[mcp_servers.dcision]
url = "https://api.dcision.io/mcp"
bearer_token_env_var = "DCISION_API_KEY"
```
```json
{
"mcpServers": {
"dcision": {
"httpUrl": "https://api.dcision.io/mcp",
"headers": { "Authorization": "Bearer $DCISION_API_KEY" }
}
}
}
```
* **Cursor** — `~/.cursor/mcp.json` for every project, or `.cursor/mcp.json` in a repository.
* **VS Code** — `.vscode/mcp.json`; VS Code asks for the key once and stores it securely.
* **Claude Desktop** — *Settings → Developer → Edit Config* (`claude_desktop_config.json`). Claude Desktop connects to remote servers through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), a community bridge that needs Node.js; the header is passed through an environment variable so the space after `Bearer` survives on every platform. Restart Claude Desktop after editing.
* **Codex** — `~/.codex/config.toml`, with `DCISION_API_KEY` exported where Codex runs.
* **Gemini CLI** — `~/.gemini/settings.json` or `.gemini/settings.json` in a project.
Connectors in ChatGPT and on claude.ai sign in with OAuth, which the Dcision MCP server doesn't offer yet: it only accepts an API key in a header. Use one of the clients above, or the [REST API](https://docs.dcision.io/docs/api).
## Call the server with curl [#call-the-server-with-curl]
The server speaks plain JSON-RPC 2.0 over HTTP, and it is stateless — no `initialize` handshake is needed. Send `Content-Type: application/json` and `Accept: application/json, text/event-stream`:
```bash
curl -s https://api.dcision.io/mcp \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
Call a tool with `tools/call`, its `name` and its `arguments`:
```bash
curl -s https://api.dcision.io/mcp \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "get_decision", "arguments": { "slug": "lead-qualification" } }
}' | jq '.result.structuredContent'
```
The answer is a JSON-RPC response: `result.content[0].text` holds the tool's JSON as text and `result.structuredContent` the same value as an object (abridged):
```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "{\"slug\": \"lead-qualification\", \"status\": \"deployed\", \"version\": 3}" }],
"structuredContent": { "slug": "lead-qualification", "status": "deployed", "version": 3 }
}
}
```
This is also how the [Dcision skill](https://docs.dcision.io/docs/claude-code#without-the-mcp-server) reaches `validate_schema`, `check_state` and the templates when no MCP client is configured.
## Security [#security]
* The server acts only on the key's workspace; a key can't reach another workspace's decisions.
* Tools never return the inputs of past executions, secrets, destination configuration (URLs, headers) or the full schema of a decision — only the contract of its active version. Results of `run_decision` and `list_executions` do include the `destinations` entries of each run, which may carry values mapped from the state (function params, LLM or agent replies): treat them like any decision output.
* Treat a key used by an agent like any production secret: keep it in the environment, scope it to a test key while experimenting, and revoke it in **API Keys** if it leaks.
---
# Lead qualification
> Score purchase intent, set a priority, rank with a lead score and route inbound leads to sales, SDRs or nurture — and block spam — with the Lead Qualification template.
Source: https://docs.dcision.io/docs/guides/lead-qualification
**Goal:** every inbound lead (contact form, chat, e-mail) gets a purchase-intent score, a priority, a lead score and a destination team in a few hundred milliseconds, and obvious spam never reaches your CRM. The template combines three [patterns](https://docs.dcision.io/docs/patterns): intent routing, confidence-gated routing and composite scoring.
## Create the decision [#create-the-decision]
In the app, open **Templates → Lead Qualification → Create decision**. The draft contains:
| Part | Content |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **State** | `message` (string, required), `company_size` (number), `source` (string) |
| `purchase_intent` | probability — real intent to buy in the next 30 days? *Yes*: asks for pricing, a demo, a quote or a start date. *No*: just browsing, student, vendor or spam. |
| `priority` | score — `low`, `medium`, `high`, `critical` |
| `route` | choice — `sales` (high intent, ready to talk to sales), `sdr` (some intent, needs qualification), `nurture` (early stage, send content), `spam` (spam, vendor pitch or irrelevant), `other` |
| **Composite** | `lead_score` = 2 × `purchase_intent` + 1 × `priority` + 1 × p(`route` = `sales`), from 0 to 1 — see [Composites](https://docs.dcision.io/docs/concepts/composites) |
| **Policies** | 1. `route` = `spam` → `block` · 2. confidence of `purchase_intent` below 0.6 → `escalate` |
Test it in the **Playground**, then **Deploy v1**.
## Call it [#call-it]
```bash
curl -X POST https://api.dcision.io/v1/decisions/lead-qualification \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: lead-8421" \
-d '{
"state": {
"message": "We need pricing for 500 users and want to start next month.",
"company_size": 500,
"source": "website"
}
}'
```
```json title="200 OK"
{
"decision_id": "dec_3fKq9ZtW1mXcV7bN2pLa",
"execution_id": "exec_8HsT2kQw9ZyR4vMn1cXe",
"schema": "lead-qualification",
"version": 1,
"result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
"confidence": { "purchase_intent": 0.8824, "priority": 0.69, "route": 0.85 },
"scores": { "priority": 2.81 },
"composites": { "lead_score": 0.8414 },
"action": "continue",
"action_reason": { "type": "default" },
"metrics": { "latency_ms": 412, "engine": "jev", "model": "jev-1.13.0", "estimated_cost_usd": 0.00001575, "input_tokens": 375, "output_tokens": 36 }
}
```
Other outcomes you will see:
| Lead | `action` | `action_reason` |
| --------------------------------------------------------------- | -------------------------------- | ------------------------------------------------- |
| A vendor pitch: `route` = `spam` | `block` | `{ "type": "policy", "rule": 0 }` |
| An ambiguous message: `purchase_intent` = 0.45 (confidence 0.1) | `escalate` | `{ "type": "policy", "rule": 1 }` |
| Nothing fits — a job application: `route` = `other` | `escalate` (the fallback action) | `{ "type": "other_option", "question": "route" }` |
The confidence of a probability is `|2p − 1|`, so rule 2 escalates every lead whose `purchase_intent` is between 0.2 and 0.8 — see [Confidence and probabilities](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#minimum-confidence-on-probability-questions).
## Act on it [#act-on-it]
```js
async function qualify(lead) {
const response = await fetch("https://api.dcision.io/v1/decisions/lead-qualification", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DCISION_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `lead-${lead.id}`,
},
body: JSON.stringify({ state: { message: lead.message, company_size: lead.companySize, source: lead.source } }),
});
if (!response.ok) return crm.assign(lead, "sdr"); // on errors, a person decides
const { action, result, composites, execution_id } = await response.json();
switch (action) {
case "block":
return crm.discard(lead, { reason: "spam", execution_id });
case "escalate":
return crm.assign(lead, "sdr", { note: "needs review", execution_id });
default: {
const sla = { critical: "1h", high: "4h", medium: "1d", low: "3d" }[result.priority];
return crm.assign(lead, result.route, { sla, score: composites.lead_score, execution_id });
}
}
}
```
Sort each team's queue by `lead_score` to work the best leads first. Fields the decision doesn't declare are still forwarded to the engine, so you can add context such as `country` or `page` without changing the schema.
## Tune it [#tune-it]
* **Describe your business** in the decision's `context`: what you sell, to whom, your price range. It's sent with every lead.
* **Sharpen the boundaries** between `sales`, `sdr` and `nurture` with exclusions ("not for existing customers", "no budget or timeline mentioned"). If the Playground flags a near tie, the descriptions overlap.
* **Escalate critical leads** to a person even when routing is confident:
```json
{ "field": "priority", "on": "output", "operator": "gte", "value": "critical", "action": "escalate" }
```
* **Escalate on the weighted level** to catch leads that lean critical even when `high` is the most likely level: `{ "field": "priority", "on": "score", "operator": "gte", "value": 3.5, "action": "escalate" }`.
* **Require confident routing** with `"minConfidence": 0.7` on `route`: below it, the fallback action applies.
* **Tune `lead_score`** by changing its weights, or add terms — a `− 3 × spam` probability, a `company_fit` score — then route on it: `{ "field": "lead_score", "operator": "gte", "value": 0.8, "action": "continue" }`.
* **Replay real leads**: in **Executions**, open a run and *Re-run this input in the Playground* to check that an edit improves it before you deploy v2.
---
# Support routing
> Route support messages to the right department, rate their urgency and escalate to a human when needed, with the Support Routing template.
Source: https://docs.dcision.io/docs/guides/support-routing
**Goal:** each incoming support message lands in the right queue with an urgency level, and messages that need a person — an angry customer, a legal threat, data loss, a cancellation — skip the automated answer.
## Create the decision [#create-the-decision]
**Templates → Support Routing → Create decision**:
| Part | Content |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **State** | `message` (string, required), `plan` (string) |
| `department` | choice — `billing` (invoices, charges, refunds, payment methods), `technical` (bugs, errors, integrations, outages), `account` (login, access, users, settings), `sales` (upgrades, new plans, quotes), `other` — **minimum confidence 0.6** |
| `urgency` | score — `low`, `normal`, `high`, `urgent` |
| `human_required` | probability — does this need a human instead of an automated answer? *Yes*: angry customer, legal threat, data loss, cancellation. *No*: simple question answered by docs. |
| **Policies** | 1. `human_required` ≥ 0.7 → `escalate` |
| **Fallback** | `escalate` — for `other` and for a `department` below 60% confidence |
## Call it [#call-it]
```bash
curl -X POST https://api.dcision.io/v1/decisions/support-routing \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ticket-55120" \
-d '{ "state": { "message": "I was charged twice this month and need a refund today.", "plan": "pro" } }'
```
```json title="200 OK"
{
"decision_id": "dec_Rb6Tn2Wq8Lx4Mk9Pz1Cv",
"execution_id": "exec_Ys3Fp7Kd1Qw9Bn5Lt2Gh",
"schema": "support-routing",
"version": 1,
"result": { "department": "billing", "urgency": "high", "human_required": 0.82 },
"confidence": { "department": 0.9375, "urgency": 0.61, "human_required": 0.64 },
"scores": { "urgency": 3.11 },
"action": "escalate",
"action_reason": { "type": "policy", "rule": 0 },
"metrics": { "latency_ms": 356, "engine": "jev", "model": "jev-1.13.0", "estimated_cost_usd": 0.00001386, "input_tokens": 330, "output_tokens": 33 }
}
```
`human_required` is 0.82, above the 0.7 threshold of rule 0, so the action is `escalate`. A how-to question ("How do I export invoices?") would typically come back with a low `human_required` and `continue`. A message the decision can't place — `department` below 60% confidence — takes the fallback action, `escalate`, with `action_reason.type = "low_confidence"`.
## Act on it [#act-on-it]
```python
import os
import requests
def route_ticket(ticket):
response = requests.post(
"https://api.dcision.io/v1/decisions/support-routing",
headers={
"Authorization": f"Bearer {os.environ['DCISION_API_KEY']}",
"Idempotency-Key": f"ticket-{ticket['id']}",
},
json={"state": {"message": ticket["message"], "plan": ticket["plan"]}},
timeout=10,
)
if not response.ok:
return helpdesk.assign(ticket, queue="triage") # a person decides on errors
decision = response.json()
queue = decision["result"]["department"]
if queue == "other":
queue = "triage"
priority = decision["result"]["urgency"]
if decision["action"] == "escalate":
return helpdesk.assign(ticket, queue=queue, priority=priority, agent="human")
return helpdesk.auto_reply(ticket, queue=queue, priority=priority)
```
## Tune it [#tune-it]
* **Mirror your queues**: rename or add departments as options and describe the boundaries ("refunds go to billing, even for technical failures").
* **Use the plan**: add `context` such as "Enterprise customers have a 1-hour SLA" — the `plan` field is already in the state.
* **Escalate urgent tickets** regardless of `human_required` — on the answer, or on the weighted level to also catch tickets that lean urgent:
```json
{ "field": "urgency", "on": "score", "operator": "gte", "value": 3.5, "action": "escalate" }
```
* **Tune the routing floor**: `department` ships with `"minConfidence": 0.6`. Raise it to send more unclear messages to the fallback action, lower it to automate more.
* **Answer more in the same call**: add speculative questions such as `refund_requested` or `has_reproducible_steps` — see [Speculative fan-out](https://docs.dcision.io/docs/patterns/fan-out) and its `ticket-triage` template.
* **Keep personal data out of logs** if messages contain it: turn off `storeInput` in the decision's settings.
---
# Spam detection
> Classify free text as allow, review or block and estimate its spam probability with the Spam Detection template — a decision with a text state.
Source: https://docs.dcision.io/docs/guides/spam-detection
**Goal:** screen user-generated text — comments, messages, sign-up bios — before it's published: allow the legitimate, block clear spam and send the unclear to a moderator.
## Create the decision [#create-the-decision]
**Templates → Spam Detection → Create decision**:
| Part | Content |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| **State** | **text** — the request's `state` is the message itself |
| `spam_probability` | probability — is this message spam, phishing or an unsolicited promotion? |
| `action` | choice — `allow` (legitimate message), `review` (unclear, a moderator should check), `block` (clear spam, scam or abuse), `other` |
| **Policies** | 1. `action` = `block` → `block` |
## Call it [#call-it]
With a text state, `state` is a string:
```bash
curl -X POST https://api.dcision.io/v1/decisions/spam-detection \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: comment-77310" \
-d '{ "state": "Congratulations! You won a $1000 gift card, click here to claim now." }'
```
```json title="200 OK"
{
"decision_id": "dec_Vx7pL2qR9mTz4Kc8Wn1J",
"execution_id": "exec_Hd3sQ8wF1yLm6Pz2Rk9T",
"schema": "spam-detection",
"version": 1,
"result": { "spam_probability": 0.9731, "action": "block" },
"confidence": { "spam_probability": 0.9462, "action": 0.9067 },
"action": "block",
"action_reason": { "type": "policy", "rule": 0 },
"metrics": { "latency_ms": 287, "engine": "jev", "model": "jev-1.13.0", "estimated_cost_usd": 0.0000063, "input_tokens": 150, "output_tokens": 19 }
}
```
`result.action` is the answer to the template's question named `action` (`allow`, `review` or `block`). The top-level `action` is what the policies decided. Here both say `block` because rule 0 maps one to the other.
An empty string or a JSON object is rejected before the engine runs: `422 INVALID_STATE` — "Field state must be a non-empty string."
## Make `review` actionable [#make-review-actionable]
Out of the box, a `review` answer matches no policy and returns `continue`. Add a rule so moderation is driven by the top-level action, and a probability-based safety net:
```json
[
{ "field": "action", "on": "output", "operator": "eq", "value": "block", "action": "block" },
{ "field": "spam_probability", "on": "output", "operator": "gte", "value": 0.9, "action": "block" },
{ "field": "action", "on": "output", "operator": "eq", "value": "review", "action": "escalate" }
]
```
Then your code only needs the top-level action:
```js
const decision = await response.json();
switch (decision.action) {
case "block":
return comments.reject(comment, { execution_id: decision.execution_id });
case "escalate":
return moderation.enqueue(comment, { spamProbability: decision.result.spam_probability });
default:
return comments.publish(comment);
}
```
## Tune it [#tune-it]
* **State your policy** in the decision's `context`: what is allowed on your platform (self-promotion? links? other languages?).
* **Split the judgment.** "Is this spam?" hides several questions. Ask the signals separately — does the message ask for a password, promise an unexpected reward, pressure the reader to act now, link to a domain that doesn't match the sender? — and combine them in a [composite](https://docs.dcision.io/docs/concepts/composites) or in rules. See [Writing good questions](https://docs.dcision.io/docs/guides/writing-good-questions).
* **Expect adversarial text.** Spam is written to look legitimate: test edge cases in the Playground before you deploy, and be explicit in the option descriptions.
* **Describe `other`** — for example "not spam, but off-topic or in an unsupported language" — so off-topic content reaches the fallback action instead of being forced into `allow`.
* **Keep messages out of logs** when they may contain personal data: turn off `storeInput`.
* **Batch carefully**: each message is one decision and one request. Stay under your [rate limit](https://docs.dcision.io/docs/api/rate-limits) by queueing large backfills.
---
# Agent routing
> Let an AI agent pick its first tool and decide when a task really needs a large language model, with the Agent Routing template.
Source: https://docs.dcision.io/docs/guides/agent-routing
**Goal:** before an agent spends a large-model call planning, decide cheaply which tool it should use first — and send only the tasks that need multi-step reasoning to the LLM.
## Create the decision [#create-the-decision]
**Templates → Agent Routing → Create decision**:
| Part | Content |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **State** | `task` (string, required) — what the user asked the agent |
| `selected_tool` | choice — `search` (needs fresh information from the web), `database` (needs data from our own records), `calculator` (needs a calculation), `none` (can answer directly), `other` |
| `needs_reasoning` | probability — does this task need multi-step reasoning from a large language model? |
| **Policies** | 1. `needs_reasoning` ≥ 0.8 → `fallback` |
## Call it [#call-it]
```bash
curl -X POST https://api.dcision.io/v1/decisions/agent-routing \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "state": { "task": "How many orders did we ship to Germany last week?" } }'
```
```json title="200 OK"
{
"decision_id": "dec_Ka9Wd4Tm1Xq7Rz3Ln8Bv",
"execution_id": "exec_Pq2Zr8Hv5Nc1Jt6Mw3Ds",
"schema": "agent-routing",
"version": 1,
"result": { "selected_tool": "database", "needs_reasoning": 0.31 },
"confidence": { "selected_tool": 0.8625, "needs_reasoning": 0.38 },
"action": "continue",
"action_reason": { "type": "default" },
"metrics": { "latency_ms": 301, "engine": "jev", "model": "jev-1.13.0", "estimated_cost_usd": 0.00000882, "input_tokens": 210, "output_tokens": 22 }
}
```
A lookup in your own data with little reasoning: call the `database` tool directly. A task such as "Compare our churn by plan over the last three quarters and suggest pricing changes" would come back with a high `needs_reasoning`, and rule 0 would return `fallback`.
## Act on it [#act-on-it]
```ts
type Decision = {
action: "continue" | "block" | "escalate" | "fallback";
result: { selected_tool: "search" | "database" | "calculator" | "none" | "other"; needs_reasoning: number };
};
async function handle(task: string) {
const response = await fetch("https://api.dcision.io/v1/decisions/agent-routing", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.DCISION_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ state: { task } }),
});
if (!response.ok) return planner.run(task); // on errors, fall back to the full agent
const decision = (await response.json()) as Decision;
if (decision.action !== "continue") return planner.run(task); // fallback or escalate: full LLM planning
switch (decision.result.selected_tool) {
case "database":
return tools.database(task);
case "search":
return tools.search(task);
case "calculator":
return tools.calculator(task);
case "none":
return llm.answer(task); // a direct, single-step answer
default:
return planner.run(task); // "other": no tool fits
}
}
```
A `selected_tool` of `other` with no policy match returns the fallback action (`escalate` by default) — handled above by the `action !== "continue"` branch.
## Tune it [#tune-it]
* **Describe each tool by what it can answer**, with exclusions: "our orders, customers and invoices — not product documentation". A JSON description such as `{ "what": "…", "not_for": "…", "examples": ["…"] }` makes the boundaries explicit.
* **Add your tools** as options — up to 254, plus `other` — and keep `none` for tasks the agent can answer directly. Jev can lean toward the first option: reorder the tools in the Playground and check that the answers hold.
* **Move the threshold**: lower the `needs_reasoning` rule to 0.7 if your tools often fail on complex tasks, raise it to save LLM calls.
* **Pass the conversation**: add fields such as `history` or `user_role` to the state — undeclared fields are forwarded to the engine. Send the last turns, not the whole history: unrelated context lowers accuracy.
* **Pick arguments too.** When a tool takes a closed-set argument (a region, a report type), add a choice question per argument in the same decision — one call answers the tool and its arguments. This is the [intent routing](https://docs.dcision.io/docs/patterns/intent-routing) pattern applied to tools.
---
# RAG relevance
> Check whether a retrieved chunk answers the query before calling the LLM, and decide when to retrieve more context, with the RAG Relevance template.
Source: https://docs.dcision.io/docs/guides/rag-relevance
**Goal:** stop sending irrelevant chunks to your LLM. Score each retrieved chunk against the query, keep the useful ones and retrieve more when nothing answers the question.
## Create the decision [#create-the-decision]
**Templates → RAG Relevance → Create decision**:
| Part | Content |
| --------------- | ----------------------------------------------------------------------- |
| **State** | `query` (string, required), `chunk` (string, required) |
| `relevant` | probability — does the chunk help answer the query? |
| `relevance` | score — `none`, `partial`, `direct` |
| `retrieve_more` | probability — should the system retrieve more context before answering? |
| **Policies** | none — every answer returns `continue` |
## Call it [#call-it]
```bash
curl -X POST https://api.dcision.io/v1/decisions/rag-relevance \
-H "Authorization: Bearer $DCISION_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"state": {
"query": "What is the refund window for annual plans?",
"chunk": "Annual plans can be refunded within 30 days of purchase."
}
}'
```
```json title="200 OK"
{
"decision_id": "dec_Gt5Mv1Qz7Wp3Kx9Rc2Ln",
"execution_id": "exec_Bw8Ls4Ty2Dq6Hn9Vk1Zf",
"schema": "rag-relevance",
"version": 1,
"result": { "relevant": 0.96, "relevance": "direct", "retrieve_more": 0.08 },
"confidence": { "relevant": 0.92, "relevance": 0.865, "retrieve_more": 0.84 },
"scores": { "relevance": 2.91 },
"action": "continue",
"action_reason": { "type": "default" },
"metrics": { "latency_ms": 334, "engine": "jev", "model": "jev-1.13.0", "estimated_cost_usd": 0.00001092, "input_tokens": 260, "output_tokens": 30 }
}
```
## Add policies [#add-policies]
The template has no rules, so the action is always `continue`. Two rules make the action meaningful — drop irrelevant chunks, ask for more context:
```json
[
{ "field": "relevance", "on": "output", "operator": "eq", "value": "none", "action": "block" },
{ "field": "retrieve_more", "on": "output", "operator": "gte", "value": 0.7, "action": "fallback" }
]
```
## Act on it [#act-on-it]
Score the retrieved chunks in parallel — each one is a decision — keep what passes, and only then call the LLM:
```js
async function relevantChunks(query, chunks) {
const decisions = await Promise.all(
chunks.map(async (chunk) => {
const response = await fetch("https://api.dcision.io/v1/decisions/rag-relevance", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.DCISION_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ state: { query, chunk: chunk.text } }),
});
return response.ok ? { chunk, decision: await response.json() } : { chunk, decision: null };
}),
);
const kept = decisions
.filter(({ decision }) => !decision || decision.action !== "block") // keep on errors
.sort((a, b) => (b.decision?.result.relevant ?? 0) - (a.decision?.result.relevant ?? 0));
const needMore = decisions.every(({ decision }) => decision?.action === "fallback");
return { kept: kept.map(({ chunk }) => chunk), needMore };
}
```
## Tune it [#tune-it]
* **Mind the volume**: 10 chunks per question is 10 decisions and 10 requests. Size retrieval and your [rate limit](https://docs.dcision.io/docs/api/rate-limits) together, and score only the top results of your retriever.
* **Or score a fixed-size shortlist in one call**: put the top passages in the state — `{ "query": …, "passages": [ … ] }` — and define one probability question per position, such as `passage_3`: ``"Does `passages[3]` help answer `query`?"`` (up to 64 questions per decision). One request and one billable decision for the whole shortlist; keep the state within the [token budget](https://docs.dcision.io/docs/api/limits).
* **Turn off `storeInput`** if chunks contain private documents.
* **Describe the corpus** in `context` ("internal help center for a payroll product") so the engine can judge partial matches.
* **Use `relevant` for ranking** and `relevance` for gating: the probability sorts chunks, the level drops the useless ones. For a single number, combine them in a [composite](https://docs.dcision.io/docs/concepts/composites).
---
# Writing good questions
> Phrase instructions, options and criteria that Jev answers reliably — literal wording, math and dates in code, less indirection, a filtered state, option order and no text generation.
Source: https://docs.dcision.io/docs/guides/writing-good-questions
Jev is fast, calibrated and good at common-sense judgment — and it reads your question **literally**. Most accuracy problems come from how a question is written, not from the engine. These rules follow TypeSafe's list of [known jev-1.13 limitations](https://docs.typesafe.ai/model-jaggedness/jev-1.13) and its guide to [structured questions](https://docs.typesafe.ai/primitives/advanced). Many of these limitations should ease in later Jev versions.
## 1. Say exactly what you mean [#1-say-exactly-what-you-mean]
Jev answers the question you wrote, not the one you meant. Scoping words, negations and implied conditions are read at face value.
| Instead of | Write |
| --------------------------- | ------------------------------------------------------------------------------------- |
| "Is this customer unhappy?" | "Does the customer say they are dissatisfied with the product or the service?" |
| "Is this a sales lead?" | "Does the sender ask about pricing, a demo, a quote or a start date for our product?" |
* **Put the boundary cases in the criteria**: option descriptions, score levels and the probability's `yes` / `no`.
* **When you catch yourself explaining a wrong answer** ("but I meant…"), that explanation is the missing half of the instruction.
* **When interpretation is unavoidable, split it** into two literal questions and combine them with an `and` condition or a [composite](https://docs.dcision.io/docs/concepts/composites).
## 2. Keep math, counting and dates in code [#2-keep-math-counting-and-dates-in-code]
Jev is not a calculator. Compute in your application and send the result in the state — a number or, better, a named bucket.
```json
{ "invoice": { "days_overdue": 42, "amount_band": "10k_to_100k" }, "message": "…" }
```
* **Counting**: don't ask "how many items are fruits?". Ask one yes/no question per item — or one decision per item — and add up the answers in code.
* **Dates**: don't ask which of two dates comes first. Extract the parts you need with choice questions (month, year, an explicit `not_stated` option), then compare and compute in code.
* **Names beat codes**: "dark red" works better than `#8B0000`; convert in code first.
* **Weighted levels are for thresholds**: `scores` tells you whether an answer is above or below a level, not the exact number between two levels.
## 3. Reduce indirection [#3-reduce-indirection]
Double negatives, "a property of a property" and multi-hop reasoning cost accuracy. Ask directly, and point at the part of the state the question is about by name, between backticks — dot and index paths work:
```json
{
"key": "refund_requested",
"type": "probability",
"instructions": "Does `ticket.messages[0].text` explicitly ask for a refund or a credit?"
}
```
## 4. Send only what the question needs [#4-send-only-what-the-question-needs]
Unrelated detail in the state is a distractor: accuracy falls as the state grows with content the decision doesn't need, and every extra token counts toward the [limits](https://docs.dcision.io/docs/api/limits) — about 32,000 tokens for the state plus the longest question and 64,000 for the state plus all questions.
* **Filter in your code first.** Fields the schema doesn't declare are still forwarded to the engine, so don't send whole records "just in case".
* **Pass the relevant slice**: the last messages of a conversation, the retrieved passages that passed a relevance check, the fields of the row the question is about.
* **Use a [list state](https://docs.dcision.io/docs/concepts/decision-schema#list-state)** for transcripts and batches, and an object with descriptive keys for everything else.
## 5. Check the option order [#5-check-the-option-order]
Jev can lean toward the option that comes first in a choice question. Make the options mutually exclusive, then **reorder them in the Playground and check that the answer stays the same**. The reserved `other` option is always last, whatever the order of the others.
## 6. Don't ask it to generate [#6-dont-ask-it-to-generate]
Jev returns choices, levels and probabilities — never free text, and neither does Dcision. For extraction, find the candidates in code (a regular expression, a parser or an LLM) and let a choice pick the right one. Option values are snake_case keys, so put each candidate in its description:
```json
{
"key": "customer_name",
"type": "choice",
"instructions": "Which candidate is the organization the invoice was issued to, as written in `source_text`?",
"options": [
{ "value": "candidate_1", "description": "Beaver Logistics" },
{ "value": "candidate_2", "description": "Beaver Dam Logistics" },
{ "value": "candidate_3", "description": "Dam Logistics" }
]
}
```
## 7. Align instructions and criteria [#7-align-instructions-and-criteria]
Criteria extend the instruction; they shouldn't contradict it. A probability whose `yes` criterion describes the *no* case performs worse. Write what an average reader would understand at first glance.
## 8. Treat the state as data [#8-treat-the-state-as-data]
The state is content, not instructions — and content can be written to steer the answer: an injected instruction, a misleading framing, text that argues for its own classification. Be explicit in the criteria about what counts, and run adversarial samples through the Playground before you deploy.
## 9. Use structure when it helps [#9-use-structure-when-it-helps]
Instructions, option descriptions, score levels and probability criteria accept JSON as well as text. Structure helps when a question has several parts or needs supporting data — the keys label each part. This choice describes what each option covers **and doesn't cover**:
```json
{
"key": "department",
"type": "choice",
"instructions": {
"question": "Which team should handle this message?",
"focus": "Classify the customer's primary request, not every topic mentioned."
},
"options": [
{
"value": "billing",
"description": { "what": "Charges, invoices, refunds, or subscriptions", "not_for": "Order tracking or account access", "examples": ["I was charged twice", "Where is my refund?"] }
},
{
"value": "orders",
"description": { "what": "Order status, delivery, cancellation, or returns", "not_for": "Charges or account access", "examples": ["Where is my package?", "Cancel my order"] }
},
{
"value": "account",
"description": { "what": "Login, password, profile, or security", "not_for": "Charges or delivery", "examples": ["I can't log in", "Change my email"] }
}
]
}
```
A score level can be a rubric too, as long as it has a `label` — the label is what `result` returns:
```json
{ "label": "critical", "rubric": "outage, data loss, security issue or money at risk" }
```
See [Structured instructions and criteria](https://docs.dcision.io/docs/concepts/questions#structured-instructions-and-criteria) for the rules and limits.
## 10. Test in the language you serve [#10-test-in-the-language-you-serve]
English is Jev's primary training language and where accuracy is best today. Other languages work, but test them on your own content and watch [confidence](https://docs.dcision.io/docs/concepts/confidence-and-probabilities) when routing.
## Checklist [#checklist]
* [ ] One judgment per question, phrased as a literal question about the state.
* [ ] Boundary cases and exclusions in the option descriptions, levels or criteria.
* [ ] Numbers, counts and dates computed in code and sent as fields or buckets.
* [ ] State fields named in the instructions with backticks when it removes ambiguity.
* [ ] Only the state the questions need.
* [ ] Options mutually exclusive, and the answer stable when you reorder them.
* [ ] Adversarial and edge-case samples run in the Playground.
* [ ] A `minConfidence` or a confidence rule where a wrong answer is costly — see [Confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing).
---
# Handling low confidence and other
> Design decisions that admit uncertainty — the reserved other option, minimum confidence, confidence policies, the fallback action, near ties and the engine-error fallback.
Source: https://docs.dcision.io/docs/guides/low-confidence-and-other
A decision that always answers confidently is lying some of the time. Jev's probabilities are calibrated, so its confidence is worth acting on, and Dcision gives you four tools to make uncertainty an explicit, routable outcome instead of a silent wrong answer.
## 1. The `other` option [#1-the-other-option]
Every choice question has a reserved `other` option, added automatically as the last option ("none of the options above"). It lets the engine say that nothing fits instead of picking the least wrong option.
When a choice answers `other` and no policy matched, the response carries the decision's **fallback action**:
```json
{
"result": { "route": "other" },
"action": "escalate",
"action_reason": { "type": "other_option", "question": "route" }
}
```
Describe `other` when "none of these" has a specific meaning for you:
```json
{ "value": "other", "description": "not a sales inquiry: job applications, partnerships, press" }
```
## 2. Minimum confidence [#2-minimum-confidence]
Set `minConfidence` on a question to treat answers below it as uncertain:
```json
{
"key": "route",
"type": "choice",
"instructions": "Which team should receive this lead?",
"minConfidence": 0.8,
"options": [ … ]
}
```
```json
{
"result": { "route": "sdr" },
"confidence": { "route": 0.52 },
"action": "escalate",
"action_reason": { "type": "low_confidence", "question": "route", "confidence": 0.52, "minConfidence": 0.8 }
}
```
Start around **0.8** (the editor's default) for choice and score questions and adjust with real traffic. For probability questions, confidence is `|2p − 1|`, the distance from a coin flip: a `minConfidence` of 0.8 flags every probability between 0.1 and 0.9, and 0.6 every probability between 0.2 and 0.8 — see [the table](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#minimum-confidence-on-probability-questions).
## 3. Confidence policies [#3-confidence-policies]
When different questions — or different answers — need different actions, write explicit rules with `"on": "confidence"`. They run **before** the `other` and minimum-confidence checks:
```json
{ "field": "purchase_intent", "on": "confidence", "operator": "lt", "value": 0.6, "action": "escalate" }
```
The response then says `{ "type": "policy", "rule": 1 }` (the rule's 0-based index).
Combine a confidence condition with an answer to give each action its own threshold — a transfer needs more certainty than a balance check:
```json
{
"field": "intent",
"on": "output",
"operator": "eq",
"value": "approve_transfer",
"and": [{ "field": "intent", "on": "confidence", "operator": "lt", "value": 0.9 }],
"action": "escalate"
}
```
That's the [confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing) pattern.
[Destinations](https://docs.dcision.io/docs/destinations) can follow the same logic: give a route its own **minimum confidence**, or send every `escalate` to a person through a webhook, a workflow or an API request — see [Triggers](https://docs.dcision.io/docs/destinations/triggers#minimum-confidence-per-route).
## 4. The fallback action [#4-the-fallback-action]
`settings.fallbackAction` is the action used for `other` answers, minimum-confidence misses and — when you opt in — engine failures. It defaults to `escalate`:
| `fallbackAction` | Use it when uncertain cases should… |
| -------------------- | --------------------------------------------------------------------- |
| `escalate` (default) | go to a person or a review queue |
| `fallback` | go to a stronger, more expensive path — a large model, more retrieval |
| `block` | be dropped (strict moderation) |
| `continue` | proceed anyway — uncertainty is only logged |
## Putting it together [#putting-it-together]
```js
const decision = await response.json();
const reason = decision.action_reason;
switch (decision.action) {
case "continue":
return automate(decision.result);
case "escalate": {
const why =
reason.type === "other_option"
? `no option fits "${reason.question}"`
: reason.type === "low_confidence"
? `"${reason.question}" is only ${Math.round(reason.confidence * 100)}% sure`
: reason.type === "engine_error"
? `the engine failed (${reason.code})`
: `policy rule ${reason.rule + 1}`;
return reviewQueue.add({ item, why, executionId: decision.execution_id });
}
case "fallback":
return largeModel.handle(item);
case "block":
return reject(item);
}
```
## Near ties [#near-ties]
Two options can share the probability mass — `{ "sales": 0.46, "sdr": 0.40 }` — with a top answer that passes a low threshold. The Playground flags these **near ties** (runner-up above 20% and less than 15 points behind). Fix them at the source: make the two option descriptions mutually exclusive, then re-run the same input and check *Compared with the previous run*. Use `?include=probabilities` to detect them in production — see [Confidence and probabilities](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#near-ties).
## Engine errors: an error or the fallback action [#engine-errors-an-error-or-the-fallback-action]
By default, the fallback action applies to **answers**, not to failures: if the engine times out or fails — after Dcision's own retries — the API returns an error (`503`, `504`, …) and no action. Route errors explicitly — most integrations treat them like `escalate` after a retry:
```js
if (!response.ok) {
const { error } = await response.json();
if (["ENGINE_TIMEOUT", "ENGINE_UNAVAILABLE", "ENGINE_RATE_LIMITED"].includes(error.code)) {
return retryLater(item); // or escalate
}
throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
}
```
Or let the decision handle it: with `"onEngineError": "fallback"` in its settings, an engine failure answers `200` with the fallback action and `{ "type": "engine_error", "code": "ENGINE_TIMEOUT" }`, an empty `result`, no charge — and the switch above handles it like any other `escalate`. Retrying later with the same `Idempotency-Key` reaches the engine again. See [Decision settings](https://docs.dcision.io/docs/concepts/settings#onengineerror).
## Monitor it [#monitor-it]
* The decision's **Overview** tab measures `other` answers, near ties, low confidence and the effect of each `minConfidence` per question, and suggests what to change — see [Overview and calibration](https://docs.dcision.io/docs/guides/overview-and-calibration).
* In **Executions**, open runs with `escalate` and read their reason: many `other_option` reasons mean your options miss a common case; many `low_confidence` reasons on one question mean its instructions or descriptions are vague — see [Writing good questions](https://docs.dcision.io/docs/guides/writing-good-questions).
* Pull the same data from code with [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions) to chart reasons and confidence over time and calibrate thresholds against outcomes.
* Re-run those inputs in the Playground after each edit, then deploy.
---
# Overview and calibration
> Read a decision's Overview tab — runs, success rate, latency, cost, actions, reasons and per-question statistics — and act on every calibration suggestion, with the thresholds behind it.
Source: https://docs.dcision.io/docs/guides/overview-and-calibration
The **Overview** is the first tab of every decision. It answers three questions about the period you pick: *is it working, what does it cost, and where does it fail or hesitate — and what should change?* The editor is in the **Editor** tab next to it; new and duplicated decisions open straight in the editor.
Pick the period — **7**, **30** (the default) or **90** days — and the traffic: **All traffic**, **API** or **Playground**. Everyone in the workspace can see the Overview, Viewers included. Days are counted in UTC.
## Indicators [#indicators]
| Indicator | What it is |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Runs** | Every execution of the period — successful or not. The arrow compares it with the previous period of the same length (*new* when that one had none). |
| **Success rate** | The share of runs without an error, with the number of errors. It turns red from 2% of errors. |
| **Latency p50 · p95** | The median and 95th-percentile latency of successful runs. |
| **Input tokens / run** | The average input tokens of successful runs — what drives the engine's cost and latency. |
| **Engine cost (est.)** | The sum of the runs' estimated engine cost, and how many versions ran in the period. |
Below them:
* **Runs per day** — runs and, dashed, errors.
* **Final action** — the share of each action callers received (successful runs and engine-error fallbacks).
* **Why** — which part of the decision chose the action: *A policy rule matched*, *Answered "other"*, *Below minimum confidence*, *Engine failed → fallback* or *No rule matched (continue)* — and how many times each rule matched (*Rules matched: #1 × 120*).
* **Errors** — failed runs by error code; each code opens **Executions** filtered on errors. Errors are never billed.
* **Traffic** — API and Playground runs. **Versions** — runs per deployed version, and the draft for Playground runs.
## Questions [#questions]
One card per question, from a sample of the **latest 2,000 successful runs** of the period. Statistics need the answers, so they only cover runs stored with the decision's [`storeOutput`](https://docs.dcision.io/docs/concepts/settings#storeinput-and-storeoutput) on.
| Metric | What it shows | Highlighted from |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| **Answers** | The share of each option or level. Probability questions show the distribution of *p(yes)* in tenths, 0 to 1. | — |
| **Confidence** | A histogram of 20 bars of 0.05, from 0 to 1. Confident answers live on the right. | — |
| **Avg confidence** | The average confidence of the answers. | below 0.6 |
| **Below minimum** | The share of answers below the question's `minConfidence` — runs that get the fallback action unless a policy matched first. | 20% |
| **Near ties** (choice, score) | The share of answers whose top two options or levels were less than 0.15 apart, and the pair confused most often. | 15% |
| **Undecided** (probability) | The share of probabilities between 0.35 and 0.65 — neither yes nor no. | 30% |
| **"other"** (choice) | The share of answers where no option fit. | 10% |
| **Avg p(yes)** (probability) | The average probability of *yes*. | — |
**Tune** on each card opens the question in the editor.
## Destinations [#destinations]
For decisions with [destinations](https://docs.dcision.io/docs/destinations), a table counts, per destination and for the period:
| Column | Counts |
| ------------- | ------------------------------------------------------------------------------------------ |
| **Returned** | Answers handed back in the response — functions, fixed replies, and LLM and agent answers. |
| **Delivered** | Deliveries that succeeded: webhooks, API requests, workflows and agent hand-offs. |
| **Failed** | Deliveries that failed, and LLM or agent calls that failed. |
| **Pending** | Deliveries still waiting for an attempt. |
## Calibration suggestions [#calibration-suggestions]
The **Calibration suggestions** card turns the numbers into advice. Each suggestion quotes the measurement behind it, so you can check it, and links to the field to change with **Fix in the editor**. They are sorted by severity — high, medium, low, info.
* Below **20 runs** in the period, the card only says that the sample is small — no advice based on noise.
* Per-question advice also needs 20 answers of that question in the sample.
* Suggestions point at the **draft** — what you edit — or at the deployed version when the draft is invalid.
| Suggestion | Shown when | Severity | What to do |
| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| *Per-question statistics are off* | The decision doesn't store outputs. | info | Turn on **Store output** to get per-question advice. |
| *Only 12 runs in this period* | Fewer than **20** runs. | info | Run representative inputs in the Playground, or send real traffic. |
| *5% of runs failed (mostly ENGINE_TIMEOUT, 41)* | **2%** or more of runs failed — high from **10%**. | medium, high | Per code: raise `timeoutMs` or answer with the fallback action (`ENGINE_TIMEOUT`); spread bursts or use your own key (`ENGINE_RATE_LIMITED`); turn on the engine-error fallback (`ENGINE_UNAVAILABLE`); fix what callers send (`INVALID_STATE`); otherwise read the errors in Executions. |
| *34% of runs end in the fallback action (escalate)* | **30%** or more of runs got the fallback action because of `other` or low confidence. | medium | Read the per-question advice before loosening thresholds. |
| *"route" answered "other" in 14% of runs* | A choice question answered `other` in **10%** or more of its answers — high from **25%**. | medium, high | Look at those executions; add an option for the common case or widen the descriptions. |
| *"route" is a near tie in 22% of runs (mostly "sales" vs "sdr")* | The top two answers of a choice or score question were less than **0.15** apart in **15%** or more of its answers — high from **30%**. | medium, high | Contrast the two descriptions and add exclusions ("not for existing customers"); Jev leans toward the first option, so put the more specific one first. For levels, describe each one with concrete criteria. |
| *Average confidence on "priority" is 0.52* | The average confidence of a question is below **0.6**. | medium | Ask one literal question, split compound ones, point at the state fields that matter with backticks, or add background in `context`. |
| *minConfidence 0.8 sends 27% of "route" to escalate* | **20%** or more of a question's answers fall below its `minConfidence`. | medium | The card simulates the share below **0.5, 0.6, 0.7, 0.8 and 0.9** and names the highest threshold that keeps it at **10%** or less. Lower the threshold only if answers near it are usually right; otherwise improve the question first. |
| *Uncertain "route" answers can trigger blocking rules* | The question has no `minConfidence`, the decision has a `block` rule, and some answers fall below **0.7**. | low | Set a `minConfidence` so uncertain answers get the fallback action instead of being blocked. |
| *"purchase_intent" is undecided (p between 0.35 and 0.65) in 31% of runs* | A probability between **0.35 and 0.65** in **30%** or more of its answers. | medium | Add *counts as yes* and *counts as no* criteria. |
| *"route" answered "sales" in 93% of runs* | One answer of a choice or score question in **90%** or more of at least **50** answers. | low | Either the traffic is uniform or the question doesn't discriminate: sharpen the other options, or remove the question to save tokens. |
| *"spam" never chosen in 412 runs* | An option or level was never chosen in at least **200** answers (`other` aside). | low | Check its description, or remove it if that case doesn't happen. |
| *Rule #3 (IF route eq spam THEN block) never matched in 1,250 runs* | At least **100** runs and a rule never matched. | low | An earlier rule may always win, or the value is out of reach. |
| *Rule #1 (…) matches 97% of runs* | At least **100** runs and a rule matched **95%** or more of them. | low | The rule may be broader than intended — later rules never get a chance. |
| *2,480 input tokens per decision on average* | More than **2,000** input tokens per run on average. | low | Shorten instructions and `context`, merge questions, send smaller states. |
| *p95 latency is 3.4 s* | The p95 latency is above **3 s**. | low | Latency grows with input tokens; on tight deadlines, answer with the fallback action on engine errors. |
| *Destination "sales_crm" failed 12% of deliveries* | At least **10** deliveries delivered or failed, and **5%** or more failed — high from **25%**. | medium, high | Open an execution to read the receiver's answer, fix the destination and resend. |
## Calibrate, step by step [#calibrate-step-by-step]
**Start from the top suggestion.** Open **Executions** filtered on the decision and read a few runs behind the number — their inputs, answers and distributions.
**Change the draft** in the editor: option descriptions, instructions, criteria, a threshold, a rule. Change one thing at a time.
**Replay those inputs** in the Playground — **Re-run this input in the Playground** from an execution — and compare with the previous run.
**Deploy** and watch the next days with the **API** traffic filter: the Overview includes every version of the period, and **Versions** shows how the runs split between them.
Thresholds such as `minConfidence` should follow the **cost of a wrong answer**, not only the fallback rate: a refund route deserves a higher bar than "send an article". See [Handling low confidence and other](https://docs.dcision.io/docs/guides/low-confidence-and-other) and [Writing good questions](https://docs.dcision.io/docs/guides/writing-good-questions).
The Overview measures how sure and how consistent the engine is — not whether it was right. To compare confidence with real outcomes, export runs with [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions) and join them with what happened in your system.
---
# Idempotent retries
> A production-ready Dcision client — idempotency keys, timeouts, which errors to retry, exponential backoff with Retry-After — in JavaScript and Python.
Source: https://docs.dcision.io/docs/guides/idempotent-retries
Networks fail. A request can time out on your side after the decision ran, a provider can be briefly overloaded, a burst can hit your rate limit. A good client retries the right errors, waits the right amount of time and never runs — or pays for — the same decision twice.
The official [SDKs](https://docs.dcision.io/docs/sdks) do all of this for you. This guide shows the rules and a complete client for when you call the API directly.
## The rules [#the-rules]
1. **One `Idempotency-Key` per operation**, reused by every retry of it — the ID of the lead, ticket or message works well. A retry of a call that succeeded is then [replayed](https://docs.dcision.io/docs/api/idempotency) for 24 hours instead of running again.
2. **Build the body the same way every time.** The replay check compares the state as parsed JSON, key order included; a different body with the same key is `409 IDEMPOTENCY_CONFLICT`.
3. **Time out on your side, a little after Dcision does.** Dcision already retries the engine — up to 2 retries — within the decision's `timeoutMs` (5 seconds by default); 10 seconds on your side leaves room for it. Raise both together.
4. **Retry only what can succeed later**: network errors, `429`, `500`, `503`, `504`, `409 IDEMPOTENCY_CONFLICT` **with** `Retry-After` — the first request with the key is still running — and `502 ENGINE_ERROR` once. Fix everything else.
5. **Honor `Retry-After`** on `429` and `409`; otherwise back off exponentially with jitter.
6. **Cap the attempts**, then take your error path — usually the same as `escalate`.
## JavaScript [#javascript]
```js title="dcision.js"
import { randomUUID } from "node:crypto";
const API = "https://api.dcision.io/v1/decisions";
const RETRYABLE_STATUS = new Set([429, 500, 503, 504]);
export class DcisionError extends Error {
constructor(status, error) {
super(`${error.code}: ${error.message}`);
Object.assign(this, { status, code: error.code, requestId: error.request_id, details: error.details });
}
}
/** Runs a decision with retries. Pass a stable idempotencyKey (e.g. the record's ID) per operation. */
export async function decide(slug, state, { idempotencyKey = randomUUID(), attempts = 4, timeoutMs = 10_000 } = {}) {
for (let attempt = 1; ; attempt++) {
let response;
try {
response = await fetch(`${API}/${slug}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DCISION_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ state }),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (networkError) {
// The decision may or may not have run: the same Idempotency-Key makes the retry safe.
if (attempt >= attempts) throw networkError;
await sleep(backoff(attempt));
continue;
}
const body = await readJson(response);
if (response.ok) return body;
const error = body?.error ?? { code: "INTERNAL_ERROR", message: `HTTP ${response.status}` };
const retryable =
RETRYABLE_STATUS.has(response.status) ||
(error.code === "ENGINE_ERROR" && attempt === 1) ||
// Same key still running: wait for Retry-After, then get its stored response.
(error.code === "IDEMPOTENCY_CONFLICT" && response.headers.has("Retry-After"));
if (!retryable || attempt >= attempts) throw new DcisionError(response.status, error);
const retryAfter = Number(response.headers.get("Retry-After"));
await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
}
}
async function readJson(response) {
try {
return await response.json();
} catch {
return null; // e.g. an HTML error page from a proxy
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoff = (attempt) => Math.min(8_000, 250 * 2 ** attempt) * (0.5 + Math.random() / 2);
```
```js title="usage"
import { DcisionError, decide } from "./dcision.js";
try {
const decision = await decide("lead-qualification", { message: lead.message, company_size: lead.size }, {
idempotencyKey: `lead-${lead.id}`,
});
await act(decision);
} catch (error) {
if (error instanceof DcisionError && error.code === "INVALID_STATE") return reportBadInput(lead, error.message);
await escalate(lead, error); // retries exhausted or a non-retryable error
}
```
## Python [#python]
```python title="dcision.py"
import os
import random
import time
import uuid
import requests
API = "https://api.dcision.io/v1/decisions"
RETRYABLE_STATUS = {429, 500, 503, 504}
class DcisionError(Exception):
def __init__(self, status, error):
super().__init__(f"{error['code']}: {error['message']}")
self.status = status
self.code = error["code"]
self.request_id = error.get("request_id")
self.details = error.get("details")
def decide(slug, state, idempotency_key=None, attempts=4, timeout=10):
"""Runs a decision with retries. Pass a stable idempotency_key (e.g. the record's ID) per operation."""
key = idempotency_key or str(uuid.uuid4())
for attempt in range(1, attempts + 1):
try:
response = requests.post(
f"{API}/{slug}",
headers={
"Authorization": f"Bearer {os.environ['DCISION_API_KEY']}",
"Idempotency-Key": key,
},
json={"state": state},
timeout=timeout,
)
except requests.RequestException:
# The decision may or may not have run: the same Idempotency-Key makes the retry safe.
if attempt == attempts:
raise
time.sleep(_backoff(attempt))
continue
try:
body = response.json()
except ValueError:
body = None # e.g. an HTML error page from a proxy
if response.ok:
return body
error = (body or {}).get("error") or {"code": "INTERNAL_ERROR", "message": f"HTTP {response.status_code}"}
retryable = (
response.status_code in RETRYABLE_STATUS
or (error["code"] == "ENGINE_ERROR" and attempt == 1)
# Same key still running: wait for Retry-After, then get its stored response.
or (error["code"] == "IDEMPOTENCY_CONFLICT" and "Retry-After" in response.headers)
)
if not retryable or attempt == attempts:
raise DcisionError(response.status_code, error)
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else _backoff(attempt))
def _backoff(attempt):
return min(8.0, 0.25 * 2**attempt) * (0.5 + random.random() / 2)
```
```python title="usage"
from dcision import DcisionError, decide
try:
decision = decide(
"support-routing",
{"message": ticket["message"], "plan": ticket["plan"]},
idempotency_key=f"ticket-{ticket['id']}",
)
act(decision)
except DcisionError as error:
escalate(ticket, error)
```
## What not to do [#what-not-to-do]
* **Don't generate a new `Idempotency-Key` per attempt** — retries would run, and be billed, again.
* **Don't race your own retries.** Two requests sent at the same time with the same key never both run — the second gets `409` with `Retry-After: 1` — but it costs a round trip and a rate-limit slot. Retry after a failure or a timeout.
* **Don't retry `4xx` errors unchanged** (except `429`): `INVALID_STATE`, `DECISION_DISABLED`, `CREDITS_EXHAUSTED` or `QUOTA_EXCEEDED` won't fix themselves.
* **Don't treat `IDEMPOTENCY_CONFLICT` without `Retry-After` as transient.** It means two different requests share a key — a bug in how keys are built. With `Retry-After`, it only means the first request is still running.
## With the engine-error fallback [#with-the-engine-error-fallback]
A decision whose `onEngineError` is `fallback` answers `200` even when the engine fails, with `action_reason.type = "engine_error"`. The client above returns it like any other answer. If you'd rather have real answers when they're cheap to wait for, retry it later with the **same** key: fallback answers are neither billed nor stored for idempotency, so the retry reaches the engine again — and fires the decision's [destinations](https://docs.dcision.io/docs/destinations) again, with new delivery IDs.
```js
const decision = await decide("lead-qualification", state, { idempotencyKey: `lead-${lead.id}` });
if (decision.action_reason.type === "engine_error") queue.retryLater({ lead, idempotencyKey: `lead-${lead.id}` });
```
The [CLI](https://docs.dcision.io/docs/cli)'s `dcision decide` follows the same rules: it generates an `Idempotency-Key` when you don't pass one and retries once on network errors.
---
# Plans, quotas and billing
> Genesis, Developer, Growth and Enterprise — included decisions, prepaid credits past them, rate limits, decision and log limits — what is billed, and how credits, upgrades, downgrades, cancellations and invoices work.
Source: https://docs.dcision.io/docs/plans-and-billing
## Plans [#plans]
| | Genesis | Developer | Growth | Enterprise |
| -------------------------------------------------- | ------------ | ------------------------------ | --------------------------------- | -------------------- |
| Price | Free | US$29 / month or US$276 / year | US$199 / month or US$1,908 / year | Contract |
| Included decisions | 1M / month | 2.5M / month | 10M / month | 100M / month |
| Past the included volume, with [credits](#credits) | US$12 per 1M | US$12 per 1M | US$9 per 1M | Contract |
| Rate limit | 60 / minute | 300 / minute | 2,000 / minute | 10,000 / minute |
| Decisions | 2 | 10 | Unlimited | Unlimited |
| Execution log retention | 7 days | 14 days | 30 days | 365 days |
| Support | Community | E-mail | Priority | Dedicated, 99.9% SLA |
Every new workspace starts on **Genesis**, free during the Genesis program. Developer and Growth are self-serve; Enterprise adds committed volume, SSO/SAML, a private (VPC) deployment and a custom engine mix — talk to [sales@dcision.io](mailto:sales@dcision.io).
In Brazil, prices are in reais: Developer **R$145 / month** or **R$1,392 / year**, Growth **R$995 / month** or **R$9,552 / year**. Decisions past the included volume cost R$60 per 1M on Genesis and Developer and R$45 per 1M on Growth, paid with credits.
The **Billing** page in the app always shows the current prices and limits of your workspace.
## What is billed [#what-is-billed]
A **billable decision** is a successful call to `POST /v1/decisions/{slug}` — a `200` response that ran the decision. It counts **once**, whatever the number of questions and composites in the decision.
| Counts | Doesn't count |
| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Successful API calls with **live or test** keys | Playground runs |
| Successful API calls with **your own provider key** (the provider bills the engine call separately) | Errors — any `4xx` or `5xx` |
| | [Idempotent replays](https://docs.dcision.io/docs/api/idempotency) |
| | Engine-error fallbacks — `200` answers with `action_reason.type = "engine_error"` ([`onEngineError`](https://docs.dcision.io/docs/concepts/settings#onengineerror)) |
| | Reads: `GET /v1/me` and `GET /v1/executions` |
Billable decisions are counted per hour in a dedicated counter, so execution-log retention never changes your usage or what you pay. [Destinations](https://docs.dcision.io/docs/destinations) add nothing to the bill: deliveries, fixed replies and functions are free, and [LLM destinations](https://docs.dcision.io/docs/destinations/llm) run on your own provider key, which the provider bills. Read the current period's count from code with [`GET /v1/me`](https://docs.dcision.io/docs/api/me).
## Billing period [#billing-period]
* **Paid plans** follow the subscription cycle: monthly or yearly from the day you subscribed.
* **Genesis** follows the calendar month in UTC: the count resets on the 1st at 00:00 UTC. When a paid plan ends mid-month, Genesis counts only the decisions made after it ended (from the next full hour) — the ones the paid plan already billed never use the Genesis allowance.
## Past the included volume [#past-the-included-volume]
Every plan includes a number of decisions per billing period. Past them, decisions are paid from the workspace's prepaid [credits](#credits) at the plan's price per 1M — US$12 per 1M on Genesis and Developer is US$0.000012 per decision. Nothing extra is added to the invoice: a subscription is charged its base price only.
> Example: a Developer workspace on monthly billing uses 3.1M decisions in a cycle. 2.5M are included; the other 0.6M cost 0.6 × US$12 = **US$7.20**, paid with credits. The invoice stays at US$29.
**Genesis keeps running past its free 1M decisions a month** as long as the workspace has credits — no subscription needed. When the included decisions are used up and there are no credits left, API calls fail with `402 CREDITS_EXHAUSTED` — before the engine runs — until you add credits, the period resets or you upgrade:
```json
{
"error": {
"code": "CREDITS_EXHAUSTED",
"message": "The 1M decisions included in the Genesis plan are used up and the workspace has no credits left. Add credits or turn on automatic recharge.",
"request_id": "req_Lz4Wq8nT1vXc7Rm2Kp9H",
"details": { "used": 1000000, "included": 1000000, "availableCents": 0, "currency": "usd", "billingUrl": "https://app.dcision.io/billing" }
}
}
```
A plan configured with a hard cap, or without a price per 1M in the wallet's currency, never uses credits: once its included decisions are used up, calls fail with `402 QUOTA_EXCEEDED` until the period resets or you upgrade. `GET /v1/me` reports it in `plan.hard_cap`.
The Playground keeps working in every case. Workspace owners get an e-mail at **80%** and at **100%** of the included volume, and `GET /v1/me` reports `usage.used` against `usage.included` at any time. The **Usage** page shows the billable decisions of the current period and what the decisions past the included volume cost so far.
## Credits [#credits]
Credits are a prepaid balance of the workspace, in its billing currency. They pay for the decisions past the plan's included volume, on every plan.
### Order of consumption [#order-of-consumption]
1. **The included decisions** of the plan in the current billing period.
2. **Promotional credits** — the card bonus and vouchers. They expire; the ones that expire first are used first.
3. **The paid balance** — purchases and automatic recharges. It never expires.
4. Nothing left → `402 CREDITS_EXHAUSTED`.
### Card bonus [#card-bonus]
Adding a card gives **US$20 (R$100) in promotional credits, valid for 90 days**. It is granted once per workspace and once per card across the whole platform: the same card saved in another workspace gets no second bonus. Adding a card doesn't charge anything.
### Buy credits [#buy-credits]
The Owner buys credits on the **Billing** page and pays by card on Stripe's checkout: **US$10 to US$1,000** per purchase (**R$50 to R$5,000** in Brazil). The card is saved for the next purchases and for automatic recharge.
**Currency:** the wallet uses the workspace's billing currency — BRL for buyers in Brazil, USD for everyone else. Once the wallet exists, its currency is fixed.
### Automatic recharge [#automatic-recharge]
Optional, and off until the Owner turns it on. The Owner chooses:
* a **recharge amount** — US$10 to US$1,000 (R$50 to R$5,000);
* a **trigger** — at least US$5 (R$25), and lower than the recharge amount;
and explicitly authorizes the charges. Whenever the available credit falls below the trigger, the saved card is charged the recharge amount. The charges continue until the Owner turns automatic recharge off, which can be done anytime on the **Billing** page.
* At most **10 automatic recharges in 24 hours**.
* If the card is declined, automatic recharge **pauses** and the owners get an e-mail. Update the card or save the settings again to resume it.
### Spend cap [#spend-cap]
Optional; the default is unlimited. The Owner can cap how much credit decisions use per **billing cycle** — at least US$1 (R$5). When the cycle's spending reaches the cap, API calls past the included volume fail with `402 SPEND_CAP_REACHED` until the next cycle or until the cap is raised or removed. The cap limits usage, not automatic recharges.
```json
{
"error": {
"code": "SPEND_CAP_REACHED",
"message": "The workspace reached its spend cap of $50.00 for this billing cycle. Raise the cap or wait until 2026-11-01.",
"request_id": "req_Qm8Vr2Lx5Nc1Tw7Hk4Jd",
"details": { "spendCapCents": 5000, "spentCents": 5000, "currency": "usd", "periodEnd": "2026-11-01T00:00:00.000Z", "billingUrl": "https://app.dcision.io/billing" }
}
}
```
### Vouchers [#vouchers]
A voucher code adds promotional credit to the wallet. The Owner redeems it on the **Billing** page; each code can be redeemed once per workspace. An invalid, expired or used-up code gives the same answer.
### How usage is charged [#how-usage-is-charged]
Usage is charged to the wallet by a background job, about every minute. The API checks the available credit before running the engine, so calls already in flight when the credits run out are still charged and can leave the paid balance slightly negative. The next purchase or recharge covers it.
The **Billing** page shows the available credit, the promotional credits with their expiry dates, the paid balance, the spending of the current cycle and a statement of every credit movement. Owners also get an e-mail when credits run low without automatic recharge, when they run out, when an automatic recharge fails and when the card bonus is added.
A subscription bought before credits existed keeps its overage as a metered line on its invoice and doesn't use credits.
## Monthly or annual [#monthly-or-annual]
* Annual prices are about **20% lower**: US$276 per year is US$23 a month for Developer, US$1,908 per year is US$159 a month for Growth.
* **The included volume is pooled for the year**: 12 × the monthly volume — 30M decisions per year on Developer, 120M on Growth — usable in any month. Credits pay for the decisions past the yearly pool.
## Subscribe [#subscribe]
Only the workspace's **Owner** manages billing — plans, checkout, cancellation, payment methods, invoices, and credits: buying them, saving the card, automatic recharge, the spend cap and vouchers. Everyone else in the workspace can see the plan, the usage and the credits, read-only. See [Team, roles and account](https://docs.dcision.io/docs/team-and-roles).
1. Open **Billing**, choose a plan and monthly or annual billing.
2. Pay on Stripe's secure checkout: card, billing address, tax ID and promotion codes are supported.
3. Back in the app, the plan is active immediately, with its limits.
**Currency:** buyers in Brazil pay in BRL, everyone else in USD, based on the country at checkout. Once a workspace has a subscription or a credit wallet, its currency is fixed.
## Change plans [#change-plans]
| Change | When it applies | How it's billed |
| ----------------------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Upgrade** — a higher plan, or monthly → annual on the same plan | Immediately | The price difference is prorated and invoiced right away. If the card is declined, the change fails with `402 PAYMENT_FAILED` and nothing changes. |
| **Downgrade** — a lower plan, or annual → monthly | At the end of the current cycle | Shown as a scheduled change on the Billing page; you can cancel it until then. |
Plan changes are blocked while a payment is past due — update the payment method first.
When a downgrade lowers the decision limit below the number of decisions you have, existing decisions keep working; you can't create new ones until you are under the limit.
## Cancel [#cancel]
Cancelling takes effect **at the end of the billing period**: the plan and its limits stay until then, and you can **resume** the subscription before that date. Afterwards the workspace moves to Genesis — decisions and API keys keep working within Genesis limits, and the decisions of the paid period don't count toward the Genesis month. A cancellation made in the customer portal can be resumed in the app the same way.
## Failed payments [#failed-payments]
If a renewal fails, Stripe retries the card automatically over the following days. Meanwhile the workspace keeps its plan and the owners get an e-mail with a link to pay the invoice. If every retry fails, the subscription ends and the workspace moves to Genesis.
## Invoices and payment methods [#invoices-and-payment-methods]
The **Billing** page lists your recent invoices with links to the hosted invoice and its PDF. **Manage billing** opens Stripe's customer portal to update the payment method, billing details (name, address, tax ID), download invoices or cancel. Plan changes happen in the app, not in the portal.
## E-mails [#e-mails]
Workspace owners receive an e-mail when a plan is activated or changed, a cancellation is scheduled, a subscription ends, a payment fails, when usage reaches 80% and 100% of the included volume, and about credits: a low balance without automatic recharge, credits used up, a failed automatic recharge and the card bonus added.
---
# Team, roles and account
> Organizations and workspaces, the Owner, Admin, Member and Viewer roles, invitations, leaving and transferring, deleting a workspace, passwords, signing out everywhere and e-mail language.
Source: https://docs.dcision.io/docs/team-and-roles
## Workspaces are organizations [#workspaces-are-organizations]
A **workspace** is an organization: it has its own decisions, API keys, members, secrets, settings, plan and billing. API keys belong to one workspace, and nothing is shared between workspaces.
* **Switch** workspaces from the workspace menu at the top of the sidebar; it shows your role in each one.
* **Create** one with **Create workspace** in that menu. You become its Owner, and it starts on the free Genesis plan. You can own up to **10** workspaces.
* Every account keeps **at least one workspace**. If you leave your last one, are removed from it or it is deleted, you get a new personal workspace — *"Ana's workspace"* — so you never land in an empty app.
### Organization details [#organization-details]
**Settings → Organization** holds the company behind the workspace — Admins and the Owner can edit it:
| Field | Rules |
| -------------- | -------------------------------------------------------------------------------------- |
| Legal name | Up to 160 characters. |
| Tax ID | Up to 40 characters: letters, digits, `.`, `/`, `-` and spaces — a CNPJ, a VAT number… |
| Billing e-mail | A valid e-mail address. |
| Country | The 2-letter country code, such as `BR` or `US`. |
| Address | Line 1, line 2, city, state and postal code. |
| Website | A full `https://` URL. |
Once the workspace has a Stripe customer — after its first checkout — saving these details updates it: the legal name (or the workspace name), the billing e-mail, the address, and the tax ID printed on every invoice. Dcision's own billing and quota e-mails go to the workspace's Owner.
## Roles [#roles]
Every member has one role. Each role can do everything the roles above it in this table can, and more:
| Role | Can |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Viewer** | Read decisions, executions, usage and members. Manage their own account. Leave the workspace. |
| **Member** | Also create, edit, deploy and run decisions, and manage API keys and destinations. |
| **Admin** | Also invite, remove and change the role of Members and Viewers; edit the organization details, the workspace name and its log retention; manage provider keys and destination secrets. |
| **Owner** | Also manage billing and credits; invite, promote and remove Admins; transfer ownership; delete the workspace. |
In detail:
| Action | Viewer | Member | Admin | Owner |
| ------------------------------------------------------------------------------------------------- | ------ | ------ | ----- | ----- |
| See decisions, versions, executions — stored inputs included — usage, the Overview and deliveries | ✓ | ✓ | ✓ | ✓ |
| See the members, the plan, the usage of the period and the credits | ✓ | ✓ | ✓ | ✓ |
| Create, edit, duplicate, deploy, disable and delete decisions | — | ✓ | ✓ | ✓ |
| Run decisions in the Playground | — | ✓ | ✓ | ✓ |
| Create, rename and revoke API keys | — | ✓ | ✓ | ✓ |
| Edit destinations, see secret names, resend failed deliveries | — | ✓ | ✓ | ✓ |
| Invite Members and Viewers; change their role; remove them | — | — | ✓ | ✓ |
| Edit the workspace name, log retention and organization details | — | — | ✓ | ✓ |
| Engine settings and provider keys | — | — | ✓ | ✓ |
| Workspace secrets and the signing secret | — | — | ✓ | ✓ |
| Invite, promote, demote and remove Admins | — | — | — | ✓ |
| Billing: plans, checkout, cancellation, payment methods, invoices | — | — | — | ✓ |
| Credits: buy, save a card, automatic recharge, spend cap, vouchers | — | — | — | ✓ |
| Transfer ownership; delete the workspace | — | — | — | ✓ |
| Leave the workspace | ✓ | ✓ | ✓ | — |
A few rules always hold: a workspace has exactly **one Owner**; nobody changes their own role; the Owner role moves only through a transfer, and nobody can remove the Owner; only the Owner grants or takes away the Admin role.
Refusals answer `403 FORBIDDEN` with a message that names the role needed — *"Viewers can't change anything in this workspace. Ask an admin for the Member role."*, *"Only workspace admins can do this."*, *"Only the workspace owner can do this."* When you are no longer a member of the workspace at all, the error carries `details.reason = "workspace_access"` and the app switches you back to a workspace you belong to. See [Errors](https://docs.dcision.io/docs/api/errors#details).
Roles apply to the app. [API keys](https://docs.dcision.io/docs/api/authentication) are not people: a key can run every decision of its workspace and read its account and executions, whoever created it.
## Invitations [#invitations]
### Invite [#invite]
In **Settings → Members**, enter an e-mail under **Invite by e-mail**, pick the role — Member or Viewer; the Owner can also invite Admins — and send. Owners are never invited: ownership is transferred later.
### The e-mail [#the-e-mail]
The person receives a link to `https://app.dcision.io/invites/…`, valid for **7 days**. The e-mail is in their language when they already have an account, otherwise in yours. Names typed by people — the workspace's, the inviter's — are shown as plain text: links are removed and domain names can't be clicked, so an invitation can't carry a phishing message.
### Accepting [#accepting]
* **At sign-in**: every pending invitation for the person's e-mail is accepted automatically — with Google, an e-mail code or a password. Someone who signs up through an invitation joins the team's workspace and gets no personal one.
* **With the link**, signed in with the invited e-mail address. The page tells when the invitation was already accepted, revoked or expired, or was sent to another address — without revealing that address.
Pending invitations are listed under **Members**: **Resend** sends a new link — the previous one stops working — and **Revoke** cancels it. Only a hash of each link's token is stored.
Limits: **30 invitations per hour**, per workspace and per person, and **50 pending invitations** per workspace. Inviting someone who is already a member fails with `409 CONFLICT`.
## Leave, remove, transfer, delete [#leave-remove-transfer-delete]
| Action | Who | What happens |
| ------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Leave** the workspace | Anyone but the Owner | You lose access at once; an Admin can invite you again. The Owner transfers ownership first — or deletes the workspace. |
| **Remove** a member | Admins remove Members and Viewers; the Owner also removes Admins | The person loses access at once. The decisions and API keys they created stay. |
| **Transfer ownership** | The Owner, to any member | The member becomes the Owner; the previous Owner becomes an Admin. |
| **Delete** the workspace | The Owner, typing the workspace slug in **Settings → Danger zone** | Deletes its decisions, versions, executions, API keys, invitations, secrets and deliveries for everyone, and can't be undone. Members without another workspace get a new one. |
A workspace with a **live paid subscription can't be deleted**: cancel it in **Billing** and delete the workspace after the subscription ends. Its Stripe customer is kept, so past invoices stay available.
## Your account [#your-account]
**Account** holds what belongs to you, in every workspace — even as a Viewer.
### Signing in [#signing-in]
Sign in with **Google**, with a **6-digit code** sent by e-mail, or with a **password** if you set one.
* E-mail codes are valid for 10 minutes and 5 attempts. An e-mail address can receive 3 codes every 10 minutes and 10 a day, and a new code replaces the previous one.
* Each network can request 10 codes and make 30 sign-in attempts every 10 minutes.
* Sessions last 30 days.
### Password [#password]
A password is **optional** — set it in **Account → Password**:
* 10 to 128 characters, with letters **and** numbers;
* it can't contain your e-mail address, and common or repetitive passwords such as `password123` are refused.
Sign-in with a password gives the same answer for an unknown e-mail and a wrong password. After **5 wrong attempts**, wait **15 minutes** — or sign in with an e-mail code. Changing a password requires the current one and **signs out every other session**. Setting or changing it always sends you an e-mail, so a stolen session can't add a password silently.
### Sign out everywhere [#sign-out-everywhere]
Lost a device or used a shared computer? **Account → Sessions → Sign out everywhere** ends every session of your account, the current one included.
### Language [#language]
**Account → Language** sets the language of the e-mails Dcision sends you — sign-in codes, invitations and security notices — in **English**, **Portuguese**, **Spanish**, **Russian** or **Chinese** (`en`, `pt`, `es`, `ru`, `zh`). It starts from your browser's language when you sign up; your choice wins afterwards. Billing and quota e-mails are in English, and the app interface is being translated.
---
# CLI
> The dcision command line — save an API key, run decisions, list templates, scaffold and validate decision schemas offline — with exit codes for scripts and CI.
Source: https://docs.dcision.io/docs/cli
`@dcision/cli` installs the `dcision` command. Use it to call deployed decisions from a terminal or a script, and to keep decision schemas in version control with offline validation.
## Install [#install]
`@dcision/cli` isn't published on npm. Build it from a checkout of the Dcision repository; until this page says it is published, a package with this name on npm doesn't come from Dcision — don't install it, in CI least of all.
```bash
# In a checkout of the Dcision repository — Node.js 22 and pnpm 10
pnpm install
pnpm --filter "@dcision/cli..." build # builds @dcision/core, then the CLI
npm install -g ./packages/cli # puts `dcision` on your PATH, linked to this checkout
dcision --version
```
The `...` after the package name builds the packages the CLI depends on first. Without installing it globally, run `node packages/cli/dist/bin.js` instead of `dcision`. It needs Node.js 20 or newer.
## Quick start [#quick-start]
```bash
# Save an API key (prompted, so it stays out of your shell history)
dcision login
# Run a deployed decision
dcision decide lead-qualification \
--state '{"message":"We need pricing for 500 users and want to start next month.","company_size":500}'
# Print the raw API response, with the full distributions
dcision decide lead-qualification --state-file lead.json --probabilities --json
```
## Commands [#commands]
| Command | Description |
| --------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `dcision login [--api-key ] [--api-url ]` | Save an API key — prompted when omitted, read from stdin when piped. |
| `dcision logout` | Remove the stored key. |
| `dcision config list` | Show the configuration. |
| `dcision config get ` | Print one setting. |
| `dcision config set ` | Change a setting: `api-url` or `output`. |
| `dcision decide [options]` | Run a deployed decision. |
| `dcision templates` | List the built-in templates. |
| `dcision init [file] [--template ] [--force]` | Write a decision schema JSON (default `./decision.json`). |
| `dcision validate ` | Validate a decision schema offline. |
| `dcision check-state (--state \| --state-file )` | Validate a state against a schema offline. |
| `dcision --version`, `dcision --help` | Version and help. |
### login [#login]
```bash
dcision login # prompts for the key
dcision login --api-key "$DCISION_API_KEY" # non-interactive
echo "$DCISION_API_KEY" | dcision login # reads stdin when piped
dcision login --api-url https://api.dcision.io # also saves the API URL
```
The key is stored in `~/.config/dcision/config.json` with file mode `600` (readable only by you). `dcision logout` removes it. Prefer `--api-key` from an environment variable or stdin over typing a key in a command line that ends up in your shell history.
### config [#config]
| Key | Values | Default |
| --------- | ------------------ | ------------------------ |
| `api-url` | The API base URL | `https://api.dcision.io` |
| `output` | `pretty` or `json` | `pretty` |
```bash
dcision config set output json
dcision config get api-url
dcision config list
```
### decide [#decide]
```bash
dcision decide [--state | --state-file | --stdin] [--probabilities] [--idempotency-key ] [--json]
```
| Option | Description |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--state ` | The state as JSON: an object, an array for list decisions, or a quoted string for text decisions — `--state '"Hello"'`. Plain text is sent as a text state. |
| `--state-file ` | Read the state from a JSON file. |
| `--stdin` | Read the state from standard input. |
| `--probabilities` | Add the full distribution of every question (`?include=probabilities`). |
| `--idempotency-key ` | Use this [`Idempotency-Key`](https://docs.dcision.io/docs/api/idempotency). |
| `--json` | Print the raw API response instead of the human-readable output — including `scores`, `composites` and the token counts in `metrics`. |
Pass the state with one of `--state`, `--state-file` or `--stdin`.
The CLI reads a state as JSON when it is a JSON **object**, **array** or **string**, so it calls and checks decisions with an object, a [list](https://docs.dcision.io/docs/concepts/decision-schema#list-state) or a text state: `--state '["Hi!", "Can I get a quote for 50 seats?"]'`. Anything else — plain text, a number — is sent as text; a value that starts with `{` but doesn't parse is sent as text too, with a warning.
`decide` is safe to retry: it **generates an `Idempotency-Key`** when you don't pass one and **retries once on network errors** with the same key, so a lost response is replayed rather than run — and billed — twice.
The human-readable output shows the decision and version, the action and its reason, one row per question with its result and confidence, the distributions with `--probabilities`, and the latency, model, estimated cost and execution ID. Use `--json` for weighted levels, composites and the [destinations](https://docs.dcision.io/docs/destinations) of the response.
```bash
# Pipe states from another tool
jq -c '.lead' event.json | dcision decide lead-qualification --stdin --json
# Text decision
dcision decide spam-detection --state '"Congratulations! You won a gift card, click here."'
# Your own idempotency key, e.g. the record ID
dcision decide support-routing --state-file ticket.json --idempotency-key ticket-55120
```
### templates and init [#templates-and-init]
```bash
dcision templates # lists the nine templates: id, name, description
dcision init # writes ./decision.json
dcision init lead.json --template lead-qualification # starts from a template
dcision init triage.json --template ticket-triage # a pattern template: speculative fan-out
dcision init lead.json --template lead-qualification --force # overwrites an existing file
```
The template ids are `lead-qualification`, `support-routing`, `spam-detection`, `agent-routing`, `rag-relevance`, `ticket-triage`, `voice-banking`, `resume-screening` and `customer-service-router` — see [Templates](https://docs.dcision.io/docs/concepts/templates).
`init` writes a [decision schema](https://docs.dcision.io/docs/concepts/decision-schema) — the same JSON the app's editor shows under **Decision schema**. Without `--force`, it doesn't overwrite an existing file.
### validate and check-state [#validate-and-check-state]
```bash
dcision validate decision.json
dcision check-state decision.json --state '{"message":"Hi, can I get a demo?"}'
dcision check-state decision.json --state-file samples/lead-1.json
```
Both run **offline**, with the same rules as the app and the API: `validate` reports every schema issue with its path — questions, structured entries, composites, policies and their `and` conditions, destinations — and summarizes a valid schema: its state, questions, number of policies and destinations. `check-state` reports why a state would be rejected with `422 INVALID_STATE` — object, text and list states alike. Use them in CI to keep schemas and sample payloads in sync with your code. The engine's token budget, which also counts the questions, is checked by the API when the decision runs.
The CLI doesn't create or deploy decisions. Build and deploy them in the [app](https://app.dcision.io); use `init`, `validate` and `check-state` to review and test schemas in your repository.
## Environment variables [#environment-variables]
| Variable | Description |
| ----------------- | ---------------------------------------------------------- |
| `DCISION_API_KEY` | API key to use — handy in CI, where you don't run `login`. |
| `DCISION_API_URL` | API base URL. Default `https://api.dcision.io`. |
| `NO_COLOR` | Disable colored output. |
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | ---------------------------------------------------------------------------- |
| `0` | Success. |
| `1` | Validation failed — an invalid schema or state. |
| `2` | Usage error — an unknown command, a missing argument or conflicting options. |
| `3` | API error — the API answered with an error. |
| `4` | Network error — the API couldn't be reached. |
```bash
if ! dcision validate decision.json; then
echo "decision.json is invalid" >&2
exit 1
fi
```
---
# Security and data
> What Dcision stores and for how long, where your state goes — destinations included — how keys, secrets and passwords are protected, roles and workspace isolation, and how to keep sensitive data out.
Source: https://docs.dcision.io/docs/security
## What Dcision stores [#what-dcision-stores]
| Data | What is kept | For how long |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| Account | E-mail, name, avatar (from Google, when you sign in with it), language and — only if you set one — a password hash (scrypt) | While the account exists |
| Sign-in codes | Only a keyed hash of each 6-digit code; valid 10 minutes, 5 attempts | Deleted after a day |
| Sessions | A signed token in your browser, valid 30 days. Dcision only keeps the time of your last *Sign out everywhere* or password change | — |
| Invitations | The invited e-mail, the role, who invited and when, and a SHA-256 hash of the link's token — never the link | While the workspace exists |
| Organization details | Legal name, tax ID, billing e-mail, country, address and website — also saved on your Stripe customer for invoices | While the workspace exists |
| Decisions | Drafts and every deployed version (the schemas) | Until you delete the decision |
| API keys | A **SHA-256 hash** of the key, a masked prefix, name, environment and dates — never the key itself | Until the workspace is deleted |
| Provider keys (BYOK) | The key **encrypted with AES-256-GCM** and its last 4 characters | Until you remove it |
| Workspace secrets | Each value **encrypted with AES-256-GCM**, with its last 4 characters | Until you delete it |
| Signing secret | **Encrypted**; after a rotation, the previous one too, for 24 hours | While the workspace exists |
| Executions | Always: decision, version, source, status, error, action and reason, latency, input and output tokens, estimated cost, model, request ID and the **destination entries** of the run. The **state** only if `storeInput` is on; **answers, confidence, weighted levels, composites and distributions** only if `storeOutput` is on | Your log retention (below) |
| Destination deliveries | Status, attempts, the receiver's status code, latency and first 1,024 characters of its answer, and a masked preview of the request — never its body. The full request is kept **encrypted** until it succeeds, and for failed deliveries so they can be resent | 30 days |
| Idempotency records | A hash of the request and the response it returned | 24 hours |
| Usage counters | Number of billable decisions per hour | About 400 days, for billing |
| Billing | Stripe customer and subscription IDs, plan, status and cycle dates. Card data is handled by Stripe and never reaches Dcision | While the workspace exists |
## Where your state goes [#where-your-state-goes]
To answer a decision, Dcision sends the engine provider:
* the **state** — all of its fields, including fields the schema doesn't declare — and the decision's **`context`**, as `decision_context`;
* the question **instructions**, the **options**, **levels** and **criteria**, as text or JSON;
* the model name.
Composite weights, policies and settings stay in Dcision: they are applied after the engine answers.
[Destinations](https://docs.dcision.io/docs/destinations) can send data on after the decision — only where you configure it:
* **Webhooks and workflows** send the answers, the action and the params you map — never the state itself.
* **API requests** send what your URL, headers and body template contain.
* **Agents** receive the whole state as `input`; **LLM destinations** send the state — or the `input` you set — to your model provider, under your key.
* Fixed replies and functions are returned to the caller; nothing leaves Dcision.
See [Destination security and limits](https://docs.dcision.io/docs/destinations/security-and-limits#what-leaves-dcision).
With **Dcision's engine key**, the call goes directly to TypeSafe's API (Jev) under Dcision's account; TypeSafe states that Jev is not trained on customer requests or responses — see its [legal documents](https://docs.typesafe.ai/legal). With **your own key**, it goes to the provider you chose, under your account and its terms. See [Engines and BYOK](https://docs.dcision.io/docs/concepts/engines-and-byok).
Dcision doesn't write your state or your keys to its application logs.
## Retention and deletion [#retention-and-deletion]
* **Executions** are deleted after the shorter of your plan's retention (Genesis 7 days, Developer 14, Growth 30, Enterprise 365) and the workspace's **Execution log retention** setting.
* **Deleting a decision** deletes its versions and executions immediately.
* **Idempotency records** expire after 24 hours, whatever the decision's storage settings.
* **Revoking an API key** disables it at once; the hashed record stays for your history.
* **Destination deliveries** are deleted 30 days after they were created, whatever the log retention — also when their decision was deleted.
* **Deleting a workspace** (its Owner can) deletes its decisions, versions, executions, API keys, invitations, secrets and deliveries. The Stripe customer is kept, so past invoices stay available.
* **Sign out everywhere**, in **Account**, ends every session of your account at once.
## Keep sensitive data out [#keep-sensitive-data-out]
* Turn off **`storeInput`** — and **`storeOutput`** if answers are sensitive too — on decisions that receive personal data. See [Decision settings](https://docs.dcision.io/docs/concepts/settings).
* **Send only what the decision needs**: undeclared fields are forwarded to the engine. It also makes answers more accurate — see [Writing good questions](https://docs.dcision.io/docs/guides/writing-good-questions).
* Keep personal data out of **instructions, options and `context`**: they are part of the schema, stored with every version and sent with every call.
* Replace direct identifiers (names, e-mails, document numbers) with your own IDs when they don't change the answer.
* Shorten **Execution log retention** in **Settings**.
* Map into destination **params** only what each receiver needs: destination entries — function params included — are kept with the execution even when `storeInput` and `storeOutput` are off.
## Access control [#access-control]
* **Workspace isolation.** Every query is scoped to a workspace. An API key resolves exactly one workspace and can only run that workspace's decisions — anything else is `404 DECISION_NOT_FOUND` — and only list that workspace's executions.
* **Inputs stay in the app.** [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions) returns answers, actions and metrics but never the stored state: inputs, which may hold personal data, are only shown to workspace members in the app.
* **Keys and sessions are separate.** API keys only work on the public API (`/v1`); the app's endpoints refuse them with `403 FORBIDDEN`, and a session token is not accepted on `/v1`.
* **Sign-in.** Google, a 6-digit e-mail code, or an optional password: at least 10 characters with letters and numbers, never your e-mail or a common password, stored as a scrypt hash. Wrong passwords lock sign-in with a password for 15 minutes after 5 attempts, and the answer is the same for an unknown e-mail. Setting or changing a password sends you an e-mail, and changing it ends your other sessions.
* **Sessions.** A signed token in an `httpOnly` cookie, never exposed to the browser's JavaScript, valid 30 days. *Sign out everywhere* revokes them all.
* **Roles.** Every member is an **Owner**, **Admin**, **Member** or **Viewer**. Viewers read; Members build, deploy and run decisions, API keys and destinations; Admins also manage people, settings, provider keys and secrets; only the Owner manages billing, transfers ownership and deletes the workspace. Refusals answer `403 FORBIDDEN`. See [Team, roles and account](https://docs.dcision.io/docs/team-and-roles).
* **Workspace access.** Every app request is checked against your membership of the workspace; a workspace you don't belong to answers `403 FORBIDDEN` with `details.reason = "workspace_access"`.
## Secure integration checklist [#secure-integration-checklist]
* Call the API **from your backend** over HTTPS; never expose an API key in a browser, a mobile app or a repository.
* Use **one key per service** and revoke keys you no longer use. Use test keys for staging and CI.
* Send an **`Idempotency-Key`** so retries can't duplicate a decision — see [Idempotency](https://docs.dcision.io/docs/api/idempotency).
* Log `execution_id` and `X-Request-ID` instead of the state when you need to trace a decision.
* **Verify `Dcision-Signature`** on every webhook, workflow, agent and API request you receive from Dcision, reject old timestamps and deduplicate on the delivery ID — see [Webhooks](https://docs.dcision.io/docs/destinations/webhooks#verify-the-signature).
* Keep tokens and secret URLs in **workspace secrets** and reference them as `{{secrets.NAME}}` — never type them into a destination's URL, headers or body.
* Keep `livemode: false` events — test keys and the Playground — out of production systems.
## Responsible use [#responsible-use]
Dcision returns probabilities, not certainties. For consequential decisions about people — credit, employment, housing, healthcare, legal matters — use it to triage and route, keep a person in the loop (for example with `escalate`), and review outcomes regularly.
## Legal [#legal]
* [Privacy Policy](https://dcision.io/en/privacy)
* [Terms of Service](https://dcision.io/en/terms)
---
# Changelog
> Release notes of Dcision — v0.6 Laya engine; v0.5 prepaid credits; v0.4 MCP server, Claude Code and webhook triggers; v0.3 destinations, teams, SDKs and Overview; v0.2 full Jev coverage; v0.1 launch.
Source: https://docs.dcision.io/docs/changelog
## v0.6 — October 6, 2026 · Laya engine [#v06--october-6-2026--laya-engine]
Decisions can now run on **Laya**, an open-source decision model (Apache 2.0) by Convai Innovations, besides Jev. See [Engines and BYOK](https://docs.dcision.io/docs/concepts/engines-and-byok#laya).
### Engines [#engines]
* **Default engine per workspace.** Settings → Engine picks Jev or Laya for every decision of the workspace; each engine keeps its own credential and model.
* **Engine per decision.** The new optional [`settings.engine`](https://docs.dcision.io/docs/concepts/settings#engine) (`"jev"` or `"laya"`) pins one decision to an engine; without it, the decision follows the workspace default.
* **Laya on your server.** Save your `laya-serve` URL — and its `LAYA_API_KEY`, if set — in Settings → Engine → Laya server, then **Test** it. Models: `auto` (Laya picks the checkpoint by language), `english`, `multilingual`, `typed-decisions`. Dcision only calls public `https://` addresses.
* `metrics.engine` now reports the engine that ran (`"jev"` or `"laya"`), and `metrics.model` the Laya checkpoint that answered, e.g. `laya/multilingual`. Laya calls are estimated at `estimated_cost_usd: 0`.
## v0.5 — October 6, 2026 · Prepaid credits [#v05--october-6-2026--prepaid-credits]
Decisions past a plan's included volume are now paid with prepaid credits instead of overage on the invoice. See [Credits](https://docs.dcision.io/docs/plans-and-billing#credits).
### Billing [#billing]
* **Plan + credits.** Plans keep their included decisions per billing cycle; past them, decisions are paid from the workspace's credits at the plan's price per 1M — US$12 (R$60) on Genesis and Developer, US$9 (R$45) on Growth. Subscriptions are charged their base price only.
* **Genesis is no longer a hard cap:** past its free 1M decisions a month it keeps running while the workspace has credits. `GET /v1/me` now reports `plan.hard_cap: false` for Genesis.
* **Order of consumption:** the included decisions, then promotional credits (the ones that expire first), then the paid balance, which never expires.
* **Card bonus:** adding a card gives US$20 (R$100) in promotional credits, valid for 90 days — once per workspace and once per card.
* **Buy credits** by card on Stripe's checkout, US$10 to US$1,000 (R$50 to R$5,000) per purchase, in the workspace's billing currency.
* **Automatic recharge** (optional, with the Owner's explicit consent): when the available credit falls below a trigger, the saved card is charged the chosen amount — at most 10 times in 24 hours; a declined card pauses it and e-mails the owners.
* **Spend cap** per billing cycle (optional, unlimited by default) and **vouchers** that add promotional credits.
* Only the Owner buys credits and changes these settings; everyone else in the workspace sees the credits read-only.
### API [#api]
* New errors: **`402 CREDITS_EXHAUSTED`** — the included decisions are used up and there are no credits left — and **`402 SPEND_CAP_REACHED`** — the cycle's spend cap is reached. `402 QUOTA_EXCEEDED` remains only for plans configured with a hard cap or without a price per 1M. See [Errors](https://docs.dcision.io/docs/api/errors).
## v0.4 — October 5, 2026 · MCP server, Claude Code and webhook triggers [#v04--october-5-2026--mcp-server-claude-code-and-webhook-triggers]
Agents can now use Dcision directly, and any system can trigger a decision with a URL.
### MCP server [#mcp-server]
* **`https://api.dcision.io/mcp`**, a remote MCP server (Streamable HTTP) authenticated with your API key: `get_account`, `list_decisions`, `get_decision`, `check_state`, `run_decision`, `list_executions`, `list_templates`, `get_template` and `validate_schema`. Same keys, limits and billing as the API; only `run_decision` is billed. See [MCP server](https://docs.dcision.io/docs/mcp).
* Works with Claude Code, Cursor, VS Code, Claude Desktop, Codex and Gemini CLI — and with plain `curl`.
### Claude Code [#claude-code]
* **The Dcision skill**, published at `https://docs.dcision.io/skills/dcision/SKILL.md`: how decisions work, how to write and validate a decision schema, which calls are billed, and how to use the API with `curl` when the MCP server isn't connected. See [Use Dcision in Claude Code](https://docs.dcision.io/docs/claude-code).
### Webhook trigger [#webhook-trigger]
* **Every decision can have a webhook URL** — `POST https://api.dcision.io/v1/hooks/whk_…` — that runs its active version without an API key, for forms, CRMs, Zapier, n8n and Make. Send `{ "state": … }` or the raw payload, and protect it with a `whsec_` secret: a `Dcision-Signature` HMAC with a 300-second window, or `Authorization: Bearer whsec_…`. See [Webhook trigger](https://docs.dcision.io/docs/api/webhook-trigger).
* Managed in the decision's new **Advanced** tab: turn it on or off, rotate the URL, generate the secret, send a test and watch the latest calls live.
* Webhook runs are billed like API calls and appear in executions with `source: "webhook"`.
### API [#api-1]
* [`GET /v1/decisions`](https://docs.dcision.io/docs/api/decisions#list-decisions) lists the workspace's decisions and [`GET /v1/decisions/{slug}`](https://docs.dcision.io/docs/api/decisions#get-a-decision) returns what a decision's active version accepts and answers — state schema, questions and options, actions and an example state. Both use the read rate-limit window and aren't billed.
## v0.3 — October 5, 2026 · Destinations, teams and SDKs [#v03--october-5-2026--destinations-teams-and-sdks]
Decisions now act on their results, workspaces become teams, and the official SDKs arrive.
### Destinations [#destinations]
* **What happens next, per result.** Attach [destinations](https://docs.dcision.io/docs/destinations) to a choice option, a score level, a probability threshold or a final action. Seven types: **fixed replies** and **LLM answers** returned in the response, **agents** that answer or take over, **workflows** (n8n, Make, Zapier, Pipedream), signed **webhooks**, **API requests** with variables and secrets, and **functions** your code runs.
* **In the editor:** a **Destination** selector on every option and level, *Destination when … ≥ …* on probability questions, a **minimum confidence per route**, and destination nodes in the visual builder — whose palette and inspector now collapse.
* **Delivered for you:** webhooks, API requests, workflows and agent hand-offs are delivered at least once, with **6 attempts over about 7 hours**, `Retry-After`, a 10-second timeout, no redirects, an SSRF guard and a `Dcision-Signature` HMAC with zero-downtime rotation. Failed deliveries can be resent.
* **Workspace secrets** (`{{secrets.NAME}}`), encrypted and masked everywhere.
* **Tested safely:** the Playground previews destinations — secrets masked — until you switch on **Run destinations for real**; test keys and the Playground deliver with `livemode: false`.
* **API:** responses carry a [`destinations`](https://docs.dcision.io/docs/api/run-decision#destinations) array, [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions) returns it too, and the OpenAPI document describes the [`decision.completed`](https://docs.dcision.io/docs/api/webhooks) webhook event.
### Teams and accounts [#teams-and-accounts]
* **Roles:** Owner, Admin, Member and Viewer, enforced on every app request. `403 FORBIDDEN` now also covers role refusals; losing access to a workspace answers with `details.reason = "workspace_access"`.
* **Invitations** by e-mail, valid 7 days, accepted at sign-in or with their link.
* **Several workspaces** per person — up to 10 owned — with **organization details** printed on invoices; leave, remove, transfer ownership and delete a workspace.
* **Accounts:** an optional **password**, **Sign out everywhere**, and a **language** for e-mails — English, Portuguese, Spanish, Russian or Chinese.
See [Team, roles and account](https://docs.dcision.io/docs/team-and-roles).
### Overview [#overview]
* Every decision opens on an **Overview** tab: runs, success rate, latency, cost, actions and reasons, per-question statistics from the latest 2,000 runs, destination counts and **calibration suggestions** — from 20 runs on, each with the numbers behind it. See [Overview and calibration](https://docs.dcision.io/docs/guides/overview-and-calibration).
### SDKs [#sdks]
* **`@dcision/sdk`** (TypeScript, Node.js, Bun, Deno, edge) and **`dcision`** (Python 3.9+): `decide` with retries that never run a decision twice, function dispatch, webhook verification, `me()` and executions. Not published yet: build them from source — see [SDKs](https://docs.dcision.io/docs/sdks).
### API [#api-2]
* **Idempotency keys are reserved before the run.** A concurrent request with the same key gets `409 IDEMPOTENCY_CONFLICT` with `Retry-After: 1` instead of running again; errors release the key. See [Idempotency](https://docs.dcision.io/docs/api/idempotency).
* **`413 PAYLOAD_TOO_LARGE`** for bodies over 128 KB (it was `INVALID_REQUEST` with status 413), and `409 CONFLICT` when two creates race.
* [`GET /openapi.json`](https://docs.dcision.io/docs/api#endpoints) is documented.
### Decisions [#decisions]
* **Stricter rule validation when you save or deploy:** rule values are checked against the options, levels and ranges they compare with — *"Use a number between 0 and 1 (e.g. 0.7, not 70)."* Deployed versions keep running unchanged.
### Billing [#billing-1]
* After a paid plan ends mid-month, Genesis only counts the decisions made after it ended; a cancellation made in the customer portal can be resumed in the app; billing actions are rate limited.
### CLI [#cli]
* **List states:** `dcision decide` and `dcision check-state` read JSON arrays as list states, and `dcision validate` lists a schema's destinations.
* The CLI isn't on npm yet: [build it from source](https://docs.dcision.io/docs/cli#install).
### Documentation [#documentation]
* New sections: [Destinations](https://docs.dcision.io/docs/destinations) and [SDKs](https://docs.dcision.io/docs/sdks). New pages: [Webhook events](https://docs.dcision.io/docs/api/webhooks), [Team, roles and account](https://docs.dcision.io/docs/team-and-roles) and [Overview and calibration](https://docs.dcision.io/docs/guides/overview-and-calibration).
## v0.2 — October 5, 2026 · Full Jev coverage and patterns [#v02--october-5-2026--full-jev-coverage-and-patterns]
Dcision now covers everything Jev offers, and TypeSafe's architectural patterns are first-class features.
### Decisions [#decisions-1]
* **Structured entries**: instructions, option descriptions, score levels and probability criteria accept JSON objects and arrays as well as text, and instructions can point at state fields with backticks. Option descriptions are optional (`null`), and a score level can be a rubric with a `label`. See [Questions](https://docs.dcision.io/docs/concepts/questions#structured-instructions-and-criteria).
* **Higher limits**, aligned with Jev: up to **64 questions**, **254 options** plus `other` per choice, instructions up to **8,000 characters**, descriptions, levels and criteria up to **2,000**, `context` up to **8,000**, **50 policy rules**. See [Limits](https://docs.dcision.io/docs/api/limits).
* **List state**: a decision can take an array of strings or objects — a chat transcript, a batch of records — up to 500 items. Text states reach the engine as plain strings.
* **`decision_context`**: the decision's `context` is sent under this reserved key and no longer overwrites a `context` field your application sends.
* **Weighted levels**: responses include `scores`, the probability-weighted level of each score question, and rules can compare it with `"on": "score"`.
* **Composites**: weighted combinations of answers, returned in `composites` and usable in rules. See [Composites](https://docs.dcision.io/docs/concepts/composites).
* **AND conditions**: a rule can require up to 5 more conditions, across questions, confidence, weighted levels and composites.
* **Token budget**: states that don't fit Jev's budget — about 32,000 tokens for the state plus the longest question, 64,000 for the state plus all questions — fail fast with `422 INVALID_STATE` and a clear message.
### Confidence [#confidence]
* Confidence follows TypeSafe's formulas on **one 0–1 scale**. For probability questions it is now `|2p − 1|` instead of `max(p, 1 − p)` — 0 at a coin flip. **Review `minConfidence` and confidence rules on probability questions**: an old threshold `t` behaves like `2t − 1` now. See [Confidence and probabilities](https://docs.dcision.io/docs/concepts/confidence-and-probabilities#minimum-confidence-on-probability-questions).
### Engine [#engine]
* **Dcision's engine key runs on TypeSafe directly**, with a model choice in **Settings → Engine**: `jev-1.13.0` (pinned, default), `jev-latest` or `jev-preview`. `metrics.model` reports the exact version that answered.
* **`timeoutMs` is the decision's engine deadline**, with up to 2 retries inside it on network errors, `429`, `503`, `529` and other `5xx` answers, honoring `Retry-After`. Bursts on the shared engine key wait for a free slot instead of failing.
* **`onEngineError: "fallback"`**: when the engine fails, answer `200` with the fallback action and `action_reason.type = "engine_error"` instead of an error — not billed and not stored for idempotency. See [Decision settings](https://docs.dcision.io/docs/concepts/settings#onengineerror).
* **Token metrics**: `metrics.input_tokens` and `metrics.output_tokens` in every response. Cost estimates use input tokens only — Jev doesn't bill output.
### API [#api-3]
* [`GET /v1/me`](https://docs.dcision.io/docs/api/me): the key's workspace, the key, the plan's limits and the current period's usage.
* [`GET /v1/executions`](https://docs.dcision.io/docs/api/executions): recent executions with answers, action and metrics, filtered by decision and status, with cursor pagination. Inputs are never returned.
* Both use the same API keys, with a rate-limit window separate from decisions.
### Patterns and templates [#patterns-and-templates]
* **Four patterns** from TypeSafe — [speculative fan-out](https://docs.dcision.io/docs/patterns/fan-out), [confidence-gated routing](https://docs.dcision.io/docs/patterns/confidence-routing), [composite scoring](https://docs.dcision.io/docs/patterns/composite-scoring) and [intent routing](https://docs.dcision.io/docs/patterns/intent-routing) — documented with step-by-step builds.
* **Four new templates**, one per pattern: `ticket-triage`, `voice-banking`, `resume-screening` and `customer-service-router` — nine in total. Lead Qualification gains the `lead_score` composite and Support Routing a minimum confidence on `department`.
* The **Templates** page is organized by pattern, and the editor has a **Patterns** panel that shows which patterns a decision uses and how to adopt the others.
### Fixes [#fixes]
* A choice question could end up with one option more than the limit once `other` was added; the limit now counts the options besides `other`.
* The decision's `context` no longer replaces a `context` field of the state.
### Documentation [#documentation-1]
* New pages: [Patterns](https://docs.dcision.io/docs/patterns), [Composites](https://docs.dcision.io/docs/concepts/composites), [Writing good questions](https://docs.dcision.io/docs/guides/writing-good-questions), [Get account and usage](https://docs.dcision.io/docs/api/me) and [List executions](https://docs.dcision.io/docs/api/executions).
## v0.1 — October 2026 · Launch [#v01--october-2026--launch]
The first public release of Dcision, Decision as a Service.
### Decisions [#decisions-2]
* **Decision schema**: object state with typed fields or text state; up to 20 **choice**, **score** and **probability** questions (64 since v0.2); business `context`.
* **Uncertainty built in**: a reserved `other` option on every choice question, per-question **minimum confidence** and a **fallback action**.
* **Policies** on answers or confidence (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`) and four actions — `continue`, `block`, `escalate`, `fallback` — explained by `action_reason`.
* **Editor and visual builder** on the same draft, with auto-save, conflict detection and a live API-contract preview.
* **Five templates**: lead qualification, support routing, spam detection, agent routing and RAG relevance.
* **Playground** that runs unsaved drafts, shows distributions and near ties, compares runs and replays past inputs.
* **Deploys as immutable versions**, slugs locked after the first deploy, disable and enable without losing a version.
### API [#api-4]
* `POST https://api.dcision.io/v1/decisions/{slug}` returning typed `result`, `confidence`, `action`, `action_reason` and `metrics`, with `?include=probabilities` for full distributions.
* **API keys** `dcs_live_…` and `dcs_test_…`: shown once, stored as hashes, revocable.
* **Idempotency-Key** with 24-hour replays, `X-Request-ID` correlation, per-workspace **rate limits** with `X-RateLimit-*` headers and a single error format.
### Engines [#engines-1]
* **Jev** (TypeSafe System One) as the first engine.
* **Dcision engine key** included in every plan, or **bring your own key** for OpenRouter, TypeSafe or Vercel AI Gateway — encrypted with AES-256-GCM.
### Observability [#observability]
* **Executions** with action reason, confidence, distributions, model, latency, estimated cost and request ID; input and output storage configurable per decision.
* **Usage** and **Dashboard** with volume, latency percentiles, error rate and estimated engine cost; log retention per plan.
### Plans and billing [#plans-and-billing]
* **Genesis** (free, 1M decisions per month), **Developer**, **Growth** and **Enterprise** plans, monthly or annual, in USD or BRL.
* Stripe checkout, immediate upgrades with proration, downgrades at the end of the cycle, cancellation at period end, invoices and quota e-mails at 80% and 100%.
### Tooling [#tooling]
* **CLI** `dcision`: `login`, `decide`, `templates`, `init`, `validate` and `check-state` — built from source, as `@dcision/cli` isn't on npm yet.
* **Documentation** at docs.dcision.io with search, [`/llms.txt`](https://docs.dcision.io/llms.txt), [`/llms-full.txt`](https://docs.dcision.io/llms-full.txt) and a Markdown version of every page.
---
# FAQ
> Answers to common questions about Dcision — billing, quotas and credits, keys, models and latency, data retention, deployments and integration.
Source: https://docs.dcision.io/docs/faq
## General [#general]
### Is Dcision an LLM? [#is-dcision-an-llm]
No. Dcision is a decision layer: you define structured questions and policies, and every call returns typed answers, confidence and an action. The answers come from **Jev**, a decision model (TypeSafe's System One) trained to return calibrated probability distributions instead of generated text. Use Dcision to decide *whether* and *where* to send work; let your LLM, agent or team do the work.
### Which model answers my decisions? [#which-model-answers-my-decisions]
Jev. On Dcision's engine key the call goes to TypeSafe with the model chosen in **Settings → Engine** — `jev-1.13.0` (pinned, the default), `jev-latest` or `jev-preview`. With your own key, the provider and model you picked. `metrics.model` always reports the exact version that answered. See [Engines and BYOK](https://docs.dcision.io/docs/concepts/engines-and-byok).
### Which languages and SDKs are supported? [#which-languages-and-sdks-are-supported]
Any language that can send an HTTPS request with JSON — the API is a single `POST`; see the cURL, JavaScript and Python examples in [Run a decision](https://docs.dcision.io/docs/api/run-decision). The official [SDKs](https://docs.dcision.io/docs/sdks) for **TypeScript** and **Python** add retries that never run a decision twice, function destinations and webhook verification; they aren't on npm and PyPI yet, so build them from source. The [CLI](https://docs.dcision.io/docs/cli) covers the terminal and CI.
### Can a decision call my systems or answer my users? [#can-a-decision-call-my-systems-or-answer-my-users]
Yes, with [destinations](https://docs.dcision.io/docs/destinations): for each option, level, threshold or final action, return a fixed reply or an LLM answer, hand over to your agent, trigger a workflow, send a signed webhook, call any API with your secrets, or tell your code which function to run. Deliveries are retried for about 7 hours, and nothing is sent from the Playground until you switch on **Run destinations for real**.
### Can I call the API from a browser or a mobile app? [#can-i-call-the-api-from-a-browser-or-a-mobile-app]
No. API keys are secrets: call Dcision from your backend, a worker or your agent runtime, and send the result to the client if it needs it.
### How fast is a decision? [#how-fast-is-a-decision]
Every response reports `metrics.latency_ms`, and **Usage** shows the average and p95 latency of your decisions. All the questions of a decision are answered in one engine call, in parallel, so extra questions add little latency. The engine call has a deadline of 5 seconds by default, retries included — set it per decision with `timeoutMs`; a call that exceeds it fails with `504 ENGINE_TIMEOUT`.
### Can a decision answer something when the engine is down? [#can-a-decision-answer-something-when-the-engine-is-down]
Yes, if you opt in: with `onEngineError: "fallback"`, an engine failure answers `200` with the decision's fallback action and `action_reason.type = "engine_error"`. Those answers aren't billed. See [Decision settings](https://docs.dcision.io/docs/concepts/settings#onengineerror).
## Billing and quotas [#billing-and-quotas]
### What counts as a billable decision? [#what-counts-as-a-billable-decision]
A successful (`200`) call to `POST /v1/decisions/{slug}`, once per call however many questions it has. Playground runs, errors, idempotent replays, engine-error fallbacks and reads (`GET /v1/me`, `GET /v1/executions`) are free. Calls with test keys and with your own provider key count like any other. See [What is billed](https://docs.dcision.io/docs/plans-and-billing#what-is-billed).
### How do I check my usage from code? [#how-do-i-check-my-usage-from-code]
`GET /v1/me` returns the billable decisions used in the current period, the included volume and the period's dates, with any API key of the workspace. See [Get account and usage](https://docs.dcision.io/docs/api/me).
### What happens past the included decisions — on Genesis too? [#what-happens-past-the-included-decisions--on-genesis-too]
Every plan keeps running on prepaid **credits**, at the plan's price per 1M decisions: US$12 on Genesis and Developer, US$9 on Growth (R$60 and R$45 in Brazil). Genesis doesn't need a subscription for that. Without credits, API calls return `402 CREDITS_EXHAUSTED` until you add credits, the period resets or you upgrade. The Playground keeps working. See [Credits](https://docs.dcision.io/docs/plans-and-billing#credits).
### How do I get credits? [#how-do-i-get-credits]
The Owner adds them on the **Billing** page: adding a card gives **US$20 (R$100) in promotional credits valid for 90 days** — once per workspace and once per card — and credits can be bought by card from US$10 to US$1,000 (R$50 to R$5,000). Paid credits never expire. Vouchers add promotional credits too.
### Can credits top up by themselves — and can I cap spending? [#can-credits-top-up-by-themselves--and-can-i-cap-spending]
Yes, both are optional. **Automatic recharge** charges the saved card a chosen amount whenever the available credit falls below a trigger, after the Owner authorizes it; it can be turned off anytime. A **spend cap** per billing cycle stops calls past the included volume with `402 SPEND_CAP_REACHED` once the cycle's credit spending reaches it.
### Does bringing my own provider key make decisions free? [#does-bringing-my-own-provider-key-make-decisions-free]
No. With your own key, the provider bills the engine call, and the successful API call still counts toward your Dcision plan.
### What does `metrics.estimated_cost_usd` mean? [#what-does-metricsestimated_cost_usd-mean]
An estimate of the **engine** cost of the call — `metrics.input_tokens` times the model's price (Jev: US$0.042 per 1M input tokens; output tokens are free) — to compare decisions. It isn't what Dcision charges you.
## Decisions [#decisions]
### What is the `other` option I didn't add? [#what-is-the-other-option-i-didnt-add]
Every choice question gets a reserved `other` option so the engine can say "none of these" instead of forcing a wrong answer. When it's chosen, the decision applies its fallback action. See [Questions](https://docs.dcision.io/docs/concepts/questions#the-reserved-other-option).
### Why did my call return `escalate` when no policy says so? [#why-did-my-call-return-escalate-when-no-policy-says-so]
Look at `action_reason`: `other_option` means a choice answered `other`; `low_confidence` means an answer was below its `minConfidence`; `engine_error` means the engine failed on a decision with `onEngineError: "fallback"`. All three apply the decision's fallback action, `escalate` by default. See [Policies and actions](https://docs.dcision.io/docs/concepts/policies-and-actions).
### Why is the confidence of my probability question lower than the probability? [#why-is-the-confidence-of-my-probability-question-lower-than-the-probability]
Because confidence measures certainty in either direction, on the same scale as choice and score questions: for a probability `p` it is `|2p − 1|`. A probability of 0.9 — or 0.1 — has confidence 0.8; 0.5 has confidence 0. Set `minConfidence` with that in mind: 0.6 flags every `p` between 0.2 and 0.8. See [Confidence and probabilities](https://docs.dcision.io/docs/concepts/confidence-and-probabilities).
### What are `scores` and `composites` in the response? [#what-are-scores-and-composites-in-the-response]
`scores` holds the weighted level of each score question — the probability-weighted average of its levels, counted from 1, so 2.4 sits between the second and third level. `composites` holds the decision's weighted combinations of answers, such as a lead score. Both can drive policies. See [Questions](https://docs.dcision.io/docs/concepts/questions#score) and [Composites](https://docs.dcision.io/docs/concepts/composites).
### How many questions should one decision ask? [#how-many-questions-should-one-decision-ask]
As many as the flow needs, up to 64: they are answered in one call, and the call is billed once. Ask the speculative ones too — the answers you don't need are simply ignored. Keep each question atomic, and keep the state within the token budget. See [Speculative fan-out](https://docs.dcision.io/docs/patterns/fan-out) and [Writing good questions](https://docs.dcision.io/docs/guides/writing-good-questions).
### Does `block` reject my request? [#does-block-reject-my-request]
No. Actions are labels for your code. The response is `200 OK` with `"action": "block"`, and your application decides what blocking means.
### I edited my decision but the API still answers the old way. Why? [#i-edited-my-decision-but-the-api-still-answers-the-old-way-why]
The API runs the **active version**. Edits change the draft; deploy them to create a new version. See [Versions and deploy](https://docs.dcision.io/docs/concepts/versions-and-deploy).
### Can I change the slug? [#can-i-change-the-slug]
Until the first deploy. After that the slug is locked, because clients call it.
### How do I roll back? [#how-do-i-roll-back]
Versions are immutable and listed in **Version history**. Bring the draft back to the earlier schema and deploy it — this creates a new version with the old logic.
### Why is my state rejected with `422 INVALID_STATE`? [#why-is-my-state-rejected-with-422-invalid_state]
The message names the problem: a missing required field, a wrong type (`"500"` isn't a `number`), a text decision that received an object, a list decision that didn't receive an array, or a state larger than the limits — by itself, or together with the questions for the engine's token budget. Fields the schema doesn't declare are fine.
### Live or test key — which one should I use? [#live-or-test-key--which-one-should-i-use]
They run the same deployed versions and are billed the same; the only difference is that deliveries of [destinations](https://docs.dcision.io/docs/destinations) carry `livemode: false` for test keys. Use test keys for staging, CI and local development so you can revoke them without touching production.
## Team and account [#team-and-account]
### Can my team share a workspace? [#can-my-team-share-a-workspace]
Yes. Invite people by e-mail in **Settings → Members** as Admins, Members or Viewers — the invitation is valid for 7 days and is accepted when they sign in. Each person can belong to several workspaces and own up to 10. See [Team, roles and account](https://docs.dcision.io/docs/team-and-roles).
### Can I sign in with a password? [#can-i-sign-in-with-a-password]
Yes, if you set one in **Account → Password** — Google and e-mail codes keep working. Changing it signs out your other sessions, and **Sign out everywhere** ends them all.
## Data [#data]
### Does Dcision store the states I send? [#does-dcision-store-the-states-i-send]
By default, executions keep the state (`storeInput`) and the answers (`storeOutput`) for your plan's log retention — 7 days on Genesis. Turn either off per decision. Stored states are only shown in the app: `GET /v1/executions` never returns them. See [Security and data](https://docs.dcision.io/docs/security).
### Who processes my data? [#who-processes-my-data]
Dcision validates the state and sends it, with the decision's context and questions, to the engine provider: TypeSafe, under Dcision's account, by default, or the provider of your own key. See [Where your state goes](https://docs.dcision.io/docs/security#where-your-state-goes).