> ## 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.

# Node types and patterns

> Every node type, the ones you can use today and the ones coming soon, and patterns for putting them together.

> [!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.

Every node in a spec has a `type`. It says what the node is, either an indicator an engine detects (a **feature**) or a step Moonbounce computes from other nodes (an **aggregator**), and so what its value is and which other nodes can read it. [Describe a decision](https://docs.moonbounce.ai/learn/decisions.md) builds Northpine's spec with the three types that exist today. This page lists every type, including the ones that are designed but not built yet, and then shows patterns for combining them.

## Available today

| Type | Kind | Value |
| - | - | - |
| `boolean` | feature | P(yes): the engine's probability, 0 to 1, that the answer to a yes/no prompt is yes |
| `combine` | aggregator | A score from several inputs, each declared to raise or lower it, with learned weights |
| `calibrate` | aggregator | Its input, mapped onto the probability that the spec's `target` is true. Until a calibration is fitted it passes its input through, marked `calibrated: false` |

All three have a probability as their value, so any of them can be the spec's `output`, a `combine` term or a `calibrate` input. That is what lets you stack them: a combine can read another combine, or a calibrated score.

## Coming soon

> [!NOTE]
>
> The types below are designed but not available yet: a spec that uses one today is a 400. Names and fields may change before they ship, and this page will say when each one is live.

### Features

**`choice`** asks the engine to choose one answer out of 2 to 26 options that don't overlap. The engine returns a probability for each option, and they add up to 1. Describe every option or none: a single description pulls the answers toward that option.

```json theme={null}
"return_reason": {
  "type": "choice",
  "prompt": "What is the main reason the customer wants a refund?",
  "options": [
    { "name": "defect", "description": "The item broke, arrived damaged, or failed in normal use." },
    { "name": "shipping", "description": "The order arrived late, or never arrived." },
    { "name": "changed_mind", "description": "The item works, but the customer no longer wants it." },
    { "name": "other", "description": "Anything else." }
  ]
}
```

**`scale`** asks for a rating on 2 to 10 ordered levels, lowest first. The engine returns a probability for each level, the most likely level, and the expected level: the probability-weighted average of the level positions, counted from 0 for the lowest.

```json theme={null}
"wear": {
  "type": "scale",
  "prompt": "How much has the customer used the item?",
  "levels": [
    { "name": "unused" },
    { "name": "tried_once" },
    { "name": "used" },
    { "name": "worn_out" }
  ]
}
```

**`extract`** pulls structured data out of the content, such as an order number, following a JSON schema. The root is an object, every property is listed in `required` (write an optional value as nullable, `["string", "null"]`), and `additionalProperties` is `false`. Its value is the data itself, which your app reads from the response. It has no probability or label, so it can't feed a score or a label test; a condition can only check whether it answered.

```json theme={null}
"order": {
  "type": "extract",
  "prompt": "Which order is the ticket about?",
  "schema": {
    "type": "object",
    "properties": { "order_id": { "type": ["string", "null"], "description": "For example A-1042." } },
    "required": ["order_id"],
    "additionalProperties": false
  }
}
```

All three take the same `prompt`, `guidance` and `inputs` fields as a boolean.

### Aggregators

**`max`** and **`min`** are a fuzzy OR and AND: the largest or smallest of their inputs' probabilities. Each entry in `of` names a node, and `negate: true` reads it as 1 − p ("not"). They have no weights, so nothing is learned: use them where the logic is policy.

**`all`** and **`any`** are true or false: whether all, or any, of their conditions hold.

**`rules`** gives a label instead of a score. Its rules are tried in order and the first one whose condition holds sets the value; `else` names the label when none does. When `output` is a `rules` node, its label is the outcome, so the call sends no cut points and the spec has no `target`.

### Conditions and thresholds

`all`, `any` and `rules` test conditions. A condition never holds a number: it names a **threshold**, and every call sends a value for each threshold its spec names, in a `thresholds` object next to `outcomes`. Like cut points, threshold values live in your code and are outside the hash, so tuning one never makes a new version.

| Condition | Holds when |
| - | - |
| `{ "node": "defective", "gte": "defect_min" }` | The node's number is at least the threshold. `gt`, `lte` and `lt` work the same way. |
| `{ "node": "return_reason", "option": "shipping", "gte": "reason_min" }` | One option's probability is at least the threshold. |
| `{ "node": "return_reason", "is": "defect" }` | The most likely option is `defect`, or the `rules` node's label is. `in` takes a list. |
| `{ "node": "red_flag" }` | An `all` or `any` node is true. |
| `{ "node": "wear", "abstained": true }` | The node has no answer. |
| `{ "all": [...] }`, `{ "any": [...] }`, `{ "not": ... }` | Conditions nested inside one condition. |

For a `scale`, the number in a comparison is its expected level, so `{ "node": "wear", "gte": "max_wear" }` with `max_wear` set to 2 means "used or worse, on average".

A comparison on a node with no answer is neither true nor false but unknown. `all` is false if any part is false, `any` is true if any part is true, and a `rules` node that reaches an unknown rule before a true one has no value. Put an `abstained` rule first when you want a fallback label instead.

### What can read what

| Reader | Accepts |
| - | - |
| A `combine` term, a `min` or `max` entry | A probability: a `boolean`; one option of a `choice` or one level of a `scale`, named with `option`; or a `combine`, `calibrate`, `min` or `max` |
| A `calibrate` input | A `boolean`, `combine`, `calibrate`, `min` or `max` |
| A comparison in a condition | Any of those, an option's probability, or a `scale`'s expected level |
| A label test (`is`, `in`) | A `choice`, a `scale` or a `rules` node |

A whole `choice` or `scale` has no single probability, so a term that names one must also name an `option`, as in `{ "node": "return_reason", "option": "shipping" }`. Today the `option` field exists but any value is a 400, because no shipped node has options.

## Patterns you can use today

### Let one strong yes decide

A plain combine averages, so one confident answer moves the score by at most 1/n. When any single reason should be enough, set `"prior": "any"`. [From answers to an outcome](https://docs.moonbounce.ai/learn/scores-and-outcomes.md#when-one-answer-should-be-enough-prior-any) works through the arithmetic with Northpine's `needs_person` check.

### Group features, then combine the groups

`prior` applies to a whole combine, but often only some of its features belong together. Northpine's case is the `carrier_delay` prompt from [Suggestions and previews](https://docs.moonbounce.ai/learn/improving.md). Added as a sixth term of the average, it does its job for the late tent, ticket C, which moves from 0.540 to `grant` at 0.612. But it says "no" on almost every other ticket, so it drags them all down by about a sixth: ticket A, the ripped zipper, falls from 0.766 to 0.642.

The policy is really "any failure of ours is a reason to grant", weighed against everything else. A defect and a late carrier are two ways that can happen, and a ticket needs only one. So put the two in their own combine with `prior: "any"`, put the two reasons for caution in another, and combine the groups:

```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?" },
  "carrier_delay": { "type": "boolean", "prompt": "Did the carrier deliver the order late, or lose it?" },
  "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?" },
  "our_fault": {
    "type": "combine",
    "prior": "any",
    "terms": [{ "node": "defective" }, { "node": "carrier_delay" }]
  },
  "caution": {
    "type": "combine",
    "terms": [{ "node": "repeat_refunder" }, { "node": "high_value" }]
  },
  "grant_score": {
    "type": "combine",
    "terms": [
      { "node": "outside_window", "effect": "lowers" },
      { "node": "item_used", "effect": "lowers" },
      { "node": "our_fault" },
      { "node": "caution", "effect": "lowers" }
    ]
  },
  "grant": { "type": "calibrate", "node": "grant_score" }
}
```

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

With default weights and the same answers as on the other pages:

| Ticket | Five features | Six, flat | Grouped |
| - | - | - | - |
| A: zipper ripped | 0.766 grant | 0.642 grant | 0.718 grant |
| B: kayak, changed mind | 0.362 deny | 0.303 deny | 0.331 deny |
| C: tent, carrier late | 0.540 escalate | 0.612 grant | 0.665 grant |

C now clears the grant cut with room to spare, and A gets most of its margin back. A's `our_fault` is 0.979 because of its defect, and C's is 0.985 because of its carrier, so the "no" on the other prompt barely counts against either ticket.

Each combine reports its own contributions, so the response explains a score one level at a time. C's `grant_score` shows `our_fault` adding 0.246 and `outside_window` taking away 0.238, and its `our_fault` shows that `carrier_delay` (7.76) is the reason, not `defective` (0.40).

Keep in mind:

* With default weights, a group counts as one term of the combine that reads it. Here `caution` as a whole weighs as much as `outside_window` alone, so `high_value` now counts half as much as `outside_window`, where in the flat spec they counted the same. Group features because they share a reason, and check that the weights that follow still make sense.
* A `new_feature` suggestion adds its feature to the combine that feeds the output, here `grant_score`. If it belongs in a group, move it there before you merge, and preview the result.
* Feedback and review correct features, not groups: a feature claim names a boolean.
* The spec still holds at most 64 nodes, and no node may read itself through others.

### Try a feature before it counts

A boolean that no aggregator reads, and that isn't the `output`, is **observe-only**: the engine answers it and the evaluation records it, but it never moves the score, and its failure never fails the call. Add a feature that way, watch its answers on real traffic, and add it as a term once it answers the way you expect. [From answers to an outcome](https://docs.moonbounce.ai/learn/scores-and-outcomes.md) shows what the response looks like when one fails.

## Patterns coming soon

These use the types above that are not available yet. Each says what to do today instead.

### Fuzzy logic with `min` and `max`

Some rules are logic, not weighing. A review is spam if it promotes something else and isn't about the reviewer's own experience, or if it is junk text:

```json theme={null}
"nodes": {
  "promotes_elsewhere": { "type": "boolean", "prompt": "Does the review promote another product, shop or website?" },
  "own_experience": { "type": "boolean", "prompt": "Does the review describe the reviewer's own experience with the product?" },
  "junk_text": { "type": "boolean", "prompt": "Is the review mostly repeated text, gibberish or unrelated to the product?" },
  "promo_without_experience": {
    "type": "min",
    "of": [{ "node": "promotes_elsewhere" }, { "node": "own_experience", "negate": true }]
  },
  "spam": { "type": "max", "of": [{ "node": "promo_without_experience" }, { "node": "junk_text" }] }
},
"output": "spam",
"target": "The review is spam."
```

`min` and `max` nest like combines, and a combine can read them. Northpine's tenure rule, "a repeat refunder is only a reason for caution with under five years' tenure", is `min(repeat_refunder, not long_tenure)`, which then sits in `caution` where `repeat_refunder` is now.

Today: use a combine with `prior: "any"` for an OR, as `needs_person` does, or fold the condition into one prompt ("Has the customer had two or more refunds in the last 12 months, and been a customer for under five years?").

### Ask once, use each answer

When the possible answers can't all be true at once, one `choice` replaces several booleans, and a combine can read each option on its own. With `return_reason` from above, `our_fault` becomes:

```json theme={null}
"our_fault": {
  "type": "combine",
  "prior": "any",
  "terms": [
    { "node": "return_reason", "option": "defect" },
    { "node": "return_reason", "option": "shipping" }
  ]
}
```

Each option gets its own weight. Use separate booleans when answers can overlap: a choice's probabilities add up to 1, so a ticket that is both defective and late splits its answer between the two options.

Today: one boolean per option.

### Route to a label with `rules`

When the result is a destination rather than a yes/no, make a `rules` node the output. A support inbox routes each ticket from a boolean, a choice and a scale:

```json theme={null}
"nodes": {
  "refund_request": { "type": "boolean", "prompt": "Is the customer asking for money back?" },
  "topic": {
    "type": "choice",
    "prompt": "Which team should handle this ticket?",
    "options": [
      { "name": "billing", "description": "Charges, invoices, refunds, payment methods." },
      { "name": "bug", "description": "Something in the product is broken or behaves wrongly." },
      { "name": "account", "description": "Login, password, profile, account closure." },
      { "name": "other", "description": "Anything else, including sales inquiries." }
    ]
  },
  "urgency": {
    "type": "scale",
    "prompt": "How urgent is this ticket?",
    "levels": [
      { "name": "low", "description": "Can wait a few days." },
      { "name": "normal", "description": "Should be answered today." },
      { "name": "high", "description": "Blocks the customer's work or money is at stake." },
      { "name": "critical", "description": "Outage, security issue or legal threat." }
    ]
  },
  "route": {
    "type": "rules",
    "rules": [
      { "when": { "all": [{ "node": "topic", "is": "billing" }, { "node": "refund_request", "gte": "refund_min" }] }, "then": "refunds" },
      { "when": { "node": "urgency", "gte": "escalate_urgency" }, "then": "escalate" },
      { "when": { "node": "topic", "option": "bug", "gte": "topic_confident" }, "then": "engineering" },
      { "when": { "node": "topic", "option": "account", "gte": "topic_confident" }, "then": "accounts" }
    ],
    "else": "human_triage"
  }
},
"output": "route"
```

Each call sends the three thresholds, such as `"thresholds": { "refund_min": 0.5, "escalate_urgency": 2.5, "topic_confident": 0.7 }`. The order is the policy: a billing refund goes to `refunds` even when it is urgent, and a ticket whose topic and urgency clear none of the thresholds falls through to `human_triage`. A feature with no answer is different: the first rule that reads it is unknown, so `route` has no value. To send those tickets to a person too, start with a rule such as `{ "when": { "any": [{ "node": "refund_request", "abstained": true }, { "node": "topic", "abstained": true }, { "node": "urgency", "abstained": true }] }, "then": "human_triage" }`.

Today: a decision's output is a score, so evaluate one decision per yes/no and route in your own code.

## Next

[Engines, keys and environments](https://docs.moonbounce.ai/learn/engines-and-environments.md) covers which engine answers your features, which key can call which operation, and how test traffic stays apart from production.


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