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

# Describe a decision

> Build a decision spec one node at a time, from a single feature to Northpine's full refund-exception spec.

> [!NOTE]
>
> The examples on this page use Northpine, a fictional outdoor-gear shop whose `refund-exception` decision grants, escalates or denies refund requests outside its standard return policy. [How Moonbounce works](https://docs.moonbounce.ai/learn/how-it-works.md#the-running-example) introduces it.

In the loop, your code describes a decision, Moonbounce answers its features about each item, and feedback improves it ([How Moonbounce works](https://docs.moonbounce.ai/learn/how-it-works.md)). This page builds the description itself, the **spec**, one piece at a time, until you have Northpine's complete `refund-exception` spec.

## A decision is an id and a spec

A **decision** is a `decision_id` you choose plus a spec. The id names the call site in your app, and it stays the same while the spec changes underneath it: every version of the spec is recorded under the same id.

A `decision_id` starts with a lowercase letter, followed by up to 127 lowercase letters, digits, `_`, `.` or `-`. `refund-exception` and `quickstart.refund-request` are both valid; `Refund Exception` is not. Ids belong to your team, so another team's `refund-exception` is a different decision.

The spec is a JSON document you keep in your repository. Northpine keeps it in `moonbounce/refund-exception.json`, and its support app sends it inline with every evaluation, so whatever is merged is what runs.

## The smallest useful spec

Start with one feature. A spec with a single yes/no feature is already a decision:

```json theme={null}
{
  "format": "moonbounce.spec/0",
  "nodes": {
    "defective": {
      "type": "boolean",
      "prompt": "Does the customer describe a defect, damage on arrival, or the item failing under normal use?"
    }
  },
  "output": "defective"
}
```

* `format` is always `"moonbounce.spec/0"`.
* `nodes` is a map of named **nodes**. A node is either a **feature** (an indicator an engine detects in the content) or an **aggregator** (a node that computes a value from other nodes). Here there is one feature, of type `boolean`: a yes/no prompt. [Node types and patterns](https://docs.moonbounce.ai/learn/node-types.md) lists every type, including the ones coming soon.
* `output` names the node whose value is the decision's **score**.

The engine answers a boolean feature with its probability that the answer is yes, written P(yes). For a ticket about a zipper that ripped on the first night, `defective` might come back as `0.96`, and that is the score.

A node name is a lowercase slug: a lowercase letter, then up to 63 lowercase letters, digits or underscores. `constructor` and `prototype` are reserved. Names are the keys you see in every response, so pick ones you are happy to read in logs.

This spec needs no `target`. The score already has a meaning: it is the probability that the answer to `defective`'s prompt is yes. You will need a `target` as soon as the output is something you compute from several answers.

## A feature in full

`prompt` is the only text a boolean feature requires. Three optional fields help when a prompt needs more:

```json theme={null}
"defective": {
  "type": "boolean",
  "prompt": "Does the customer describe a defect, damage on arrival, or the item failing under normal use?",
  "guidance": "Ordinary wear after months of use is not a defect.",
  "criteria": {
    "true": "The item broke, arrived damaged, or stopped working while used as intended.",
    "false": "The customer changed their mind, the item does not fit, or they damaged it through misuse."
  },
  "inputs": ["customer_message"]
}
```

* `guidance` is background for this prompt only.
* `criteria.true` and `criteria.false` say what counts as a yes and what counts as a no. Either may be left out.
* `inputs` lists the `subject` parts this prompt reads, by their `name`. Without it, the prompt reads every subject part. `context` parts are always read. Every name must match a subject part of the call, or the evaluation is a 400 `CONTENT_INVALID`.

Northpine sends the customer's message as a subject part named `customer_message` and the order record as a `context` part named `order`, so the `defective` above reads both. Each text field holds 1 to 4,000 characters after surrounding whitespace is trimmed. Northpine's real spec uses only `prompt`: add the other fields when answers come back wrong, not up front.

### Writing good features

* **One fact per prompt.** "Is the item defective and was the request on time?" mixes two facts, and a single probability can't say which one it is unsure about. Ask two features.
* **Answerable from the content you send.** "Has the customer had two or more refunds in the last 12 months?" works for Northpine because the `order` part carries `refunds_last_12_months`. If no part of the request holds the fact, the engine can only guess.
* **Unambiguous yes or no.** "Is this a big order?" invites a judgment call; "Was the order total above \$250?" does not. Concrete thresholds give the engine, and later your reviewers, the same line to stand on.
* **A fact, not a verdict.** Ask about the situation, and leave what it means for the decision to the next step. `high_value` asks whether the total is above \$250, not whether the order is risky. Whether a yes argues for or against granting is declared once, in the combine below.

## Combine several features

One feature rarely decides a refund. Add whether the request is late and whether the item was used, then a `combine` node that turns the three answers into one score:

```json theme={null}
"nodes": {
  "outside_window": { "type": "boolean", "prompt": "Is the request more than 30 days after delivery?" },
  "item_used": {
    "type": "boolean",
    "prompt": "Has the customer used the item beyond trying it on or setting it up once?"
  },
  "defective": {
    "type": "boolean",
    "prompt": "Does the customer describe a defect, damage on arrival, or the item failing under normal use?"
  },
  "grant_score": {
    "type": "combine",
    "terms": [
      { "node": "outside_window", "effect": "lowers" },
      { "node": "item_used", "effect": "lowers" },
      { "node": "defective" }
    ]
  }
}
```

Each **term** names a node to read and its `effect` on the score: `raises` (the default) or `lowers`. A yes to `defective` pushes the grant score up; a yes to `outside_window` or `item_used` pushes it down. A combine holds 1 to 64 terms, each node at most once, and their order doesn't matter. A term usually names a feature, but any node with a probability works, including another combine: [Group features, then combine the groups](https://docs.moonbounce.ai/learn/node-types.md#group-features-then-combine-the-groups) shows when that helps. Terms also accept an `option` field, which is reserved for feature types that are not available yet: any value is a 400.

Direction lives in your code because it is policy: "a defect is a reason to grant" is a statement your team owns and reviews like any other rule. How much each term moves the score is a number, and numbers are Moonbounce's side: they live in a separate **weights** document. Weights may change how much a term counts, never which way it points: weights whose sign contradicts a term's `effect` are refused with 409 `WEIGHTS_INCOMPATIBLE`.

Before any weights are learned, a combine uses default weights: the plain average of its answers, with each `lowers` answer flipped. [From answers to an outcome](https://docs.moonbounce.ai/learn/scores-and-outcomes.md) works through the arithmetic. A combine also takes `prior` and `fixed`, which change those starting numbers; that page covers both.

## The target and calibrate

The output is now a computed score, so the spec has to say what the score is a probability of. That is the **target**, a yes/no statement about the item:

```json theme={null}
"grant": { "type": "calibrate", "node": "grant_score" }
```

```json theme={null}
"output": "grant",
"target": "The refund exception should be granted."
```

The target is required whenever `output` names a `combine` or `calibrate` node; without it the spec is a 400. It is what learned weights and calibration aim at, independent of your cut points, and it is what a `target` feedback claim answers ("yes, this refund should have been granted").

A `calibrate` node reads one input and maps it onto the probability that the target is true. Its numbers, like a combine's, are learned from feedback. Today no calibration has been fitted, so `grant` passes `grant_score` through unchanged and the response marks it `"calibrated": false`. Adding the node now means the output becomes a calibrated probability once fitting is available, without a change to your spec.

## Guidance every prompt sees

Prompts are short. The policy behind them goes in the spec-level `guidance`, up to 20,000 characters that every feature sees:

```json theme={null}
"guidance": "Northpine sells outdoor gear online. Standard policy: unused items returned within 30 days of delivery get a full refund, and the support bot handles those without this decision. This decision covers the exceptions: requests outside the window or for used items. Our own failures (defects, late or lost shipping, billing errors) are reasons to grant. Repeated refund requests and high-value items are reasons for caution, not automatic denial."
```

Guidance shapes how each prompt is answered; it doesn't add a reason to the score. Northpine's guidance calls late shipping a reason to grant, but no prompt asks about shipping, so a carrier delay can't move the score. That gap is real, and it is the one Northpine's feedback later finds.

## Meta: for people only

`meta` describes the decision for the people and tools reading it. It never affects answers or scores, and it is outside the hash, so editing it never creates a new version:

```json theme={null}
"meta": {
  "title": "Northpine refund exception",
  "description": "Refund requests outside the 30-day policy: grant, escalate to a person, or deny.",
  "owner": "northpine-support",
  "tags": ["support", "refunds"],
  "display": {
    "grant": { "text": "Grant refund" },
    "escalate": { "text": "Send to a person", "help": "Support lead reviews within a day." },
    "deny": { "text": "Deny" }
  }
}
```

`title` (up to 200 characters), `description` (4,000), `owner` (200), `tags` (up to 32, each up to 64) and `display` are all optional. `display` maps an outcome name to its label: `text` of 1 to 40 characters and an optional `help` of up to 200. Moonbounce stores `meta` with the structure and returns it when you read the structure back. Registering the same spec again through `POST /v1/decisions/{decision_id}/structures` with different `meta` updates it in place.

## The full Northpine spec

Two more features, `repeat_refunder` and `high_value`, both arguing for caution, complete the spec. Here is Northpine's decision file: the spec, next to the weights it uses and its cut points, which [From answers to an outcome](https://docs.moonbounce.ai/learn/scores-and-outcomes.md) explains.

```json moonbounce/refund-exception.json theme={null}
{
  "decision_id": "refund-exception",
  "spec": {
    "format": "moonbounce.spec/0",
    "meta": {
      "title": "Northpine refund exception",
      "description": "Northpine is a fictional outdoor-gear shop. Support tickets that fall outside the standard 30-day policy come here: grant the refund, escalate to a person, or deny.",
      "owner": "northpine-support",
      "tags": ["demo"]
    },
    "guidance": "Northpine sells outdoor gear online. Standard policy: unused items returned within 30 days of delivery get a full refund, and the support bot handles those without this decision. This decision covers the exceptions: requests outside the window or for used items. Our own failures (defects, late or lost shipping, billing errors) are reasons to grant. Repeated refund requests and high-value items are reasons for caution, not automatic denial.",
    "nodes": {
      "outside_window": { "type": "boolean", "prompt": "Is the request more than 30 days after delivery?" },
      "item_used": {
        "type": "boolean",
        "prompt": "Has the customer used the item beyond trying it on or setting it up once?"
      },
      "defective": {
        "type": "boolean",
        "prompt": "Does the customer describe a defect, damage on arrival, or the item failing under normal use?"
      },
      "repeat_refunder": {
        "type": "boolean",
        "prompt": "Has the customer had two or more refunds in the last 12 months?"
      },
      "high_value": { "type": "boolean", "prompt": "Was the order total above $250?" },
      "grant_score": {
        "type": "combine",
        "terms": [
          { "node": "outside_window", "effect": "lowers" },
          { "node": "item_used", "effect": "lowers" },
          { "node": "defective" },
          { "node": "repeat_refunder", "effect": "lowers" },
          { "node": "high_value", "effect": "lowers" }
        ]
      },
      "grant": { "type": "calibrate", "node": "grant_score" }
    },
    "output": "grant",
    "target": "The refund exception should be granted."
  },
  "weights": "default",
  "outcomes": {
    "below": "deny",
    "cuts": [
      { "at": 0.38, "outcome": "escalate" },
      { "at": 0.6, "outcome": "grant" }
    ]
  }
}
```

The nodes form a small graph, read from left to right:

```mermaid theme={null}
graph LR
  outside_window -- lowers --> grant_score
  item_used -- lowers --> grant_score
  defective -- raises --> grant_score
  repeat_refunder -- lowers --> grant_score
  high_value -- lowers --> grant_score
  grant_score --> grant
```

## Validation

The API parses a spec strictly wherever you send one: `POST /v1/evaluate`, `POST /v1/decisions/{decision_id}/structures` and `POST /v1/specs/hash`. The last needs no key and stores nothing, so it is a cheap check to run in CI. A spec that breaks a rule is a 400 with `type` `invalid_request`, a `message`, a `param` pointing at the field, and every problem listed in `details.issues`. Here is `POST /v1/specs/hash` answering a spec whose `grant_score` lists a `carrier_delay` term before that node exists:

```json theme={null}
{
  "error": {
    "type": "invalid_request",
    "message": "unknown node \"carrier_delay\"",
    "param": "spec.nodes.grant_score.terms.5",
    "details": {
      "issues": [
        {
          "code": "custom",
          "path": ["spec", "nodes", "grant_score", "terms", 5],
          "message": "unknown node \"carrier_delay\""
        }
      ]
    }
  }
}
```

The rules you can meet:

| Rule | Message |
| - | - |
| Node names are lowercase slugs, not `constructor` or `prototype` | `Invalid key in record` |
| 1 to 64 nodes | `1 to 64 nodes` |
| `output`, every term's `node` and every calibrate's `node` name an existing node | `unknown node "…"` |
| No node reads itself, directly or through others | `node references form a cycle` |
| Each node appears once among a combine's terms | `duplicate terms` |
| A `combine` or `calibrate` output has a `target` | `a score output needs a target statement` |
| `prior: "any"` has at least one `raises` term | `prior "any" needs at least one term that raises the score` |
| `prior: "any"` is not used with `fixed` | `prior "any" has no effect on fixed numbers` |
| `fixed` weights cover every term, with signs matching each `effect` | `every term needs a fixed weight whose sign matches its effect` |
| `fixed` weights name only the node's terms | `weight for a term the node does not have` |
| No `option` on a term | `"…" has no options` |
| Only the documented keys | `Unrecognized key: "…"` |
| Text fields are not empty | `Too small: expected string to have >=1 characters` |

The body itself must be strict JSON. A key repeated in the same object (two `"output"` keys, say) is refused before the spec is read, as a 400 with reason `CONTENT_INVALID` and `details.code` `DUPLICATE_KEY`, because different JSON parsers would keep different copies.

## Keep the spec static

Any change to a prompt, its guidance, criteria or inputs, the spec-level guidance, the target or the node graph gives the spec a new **structure hash**, and the API records each new structure hash as a new version. Formatting, key order, term order and `meta` don't count.

So the spec should be the same on every call. Data about the item goes in the request's `subject` and `context` parts, never into prompt or guidance text. If Northpine wrote "This order totals \$129" into its guidance, every ticket would mint its own version. Instead it sends the order as a part:

```json theme={null}
"context": [
  {
    "type": "json",
    "name": "order",
    "data": { "order_total_usd": 129, "days_since_delivery": 42, "refunds_last_12_months": 0 }
  }
]
```

How hashes and versions work in detail is on [Versions and hashes](https://docs.moonbounce.ai/learn/versions.md).

## Next

[From answers to an outcome](https://docs.moonbounce.ai/learn/scores-and-outcomes.md) follows three Northpine tickets from their answers to a score and on to `grant`, `escalate` or `deny`.


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