Skip to main content
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 introduces it.
In the loop, your code describes a decision, Moonbounce answers its features about each item, and feedback improves it (How Moonbounce works). 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:
  • 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 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:
  • 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:
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 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 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:
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:
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:
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 explains.
moonbounce/refund-exception.json
The nodes form a small graph, read from left to right:

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:
The rules you can meet: 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:
How hashes and versions work in detail is on Versions and hashes.

Next

From answers to an outcome follows three Northpine tickets from their answers to a score and on to grant, escalate or deny.