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.The gap reviews find
Northpine’s spec has no feature about shipping. After a few days of feedback and reviews, a pattern shows up: tickets where the carrier delivered late keep landing onescalate, and the owner keeps saying grant. Ticket C is typical: a tent that arrived two weeks late, used once, $189, 39 days after delivery. It scored 0.540, between the escalate cut (0.38) and the grant cut (0.60). The owner’s reason: “The carrier delay ate their return window; that’s our failure.”
No answer to the five existing features can say “the carrier was late”. The loop has to notice that a feature is missing.
What a suggestion is
A suggestion is one proposed change to your spec. Today it is one of two kinds:feature_rewrite: new wording for one existing feature (itsprompt,criteriaorguidance).new_feature: one new boolean feature, added as a term of thecombinethat feeds the output (heregrant_score).
How the suggestion writer picks a change
The suggestion writer starts from the cases it is allowed to see: your seed examples and reviewed cases outside the holdout (see Evidence labels). It replays the base on them and looks at each case whose outcome is wrong.Which feature is to blame
For each wrong case it asks, feature by feature: if this answer were firmly the opposite (a yes of 0.5 or more becomes 0, anything lower becomes 1), would the case land on the outcome it should have? Each feature that passes this test is a suspect, and the case’s blame is split evenly among its suspects. Two kinds of correction change the test. A feature claim that disagrees with the engine (yours, or a reviewer marking a feature wrong) names that feature directly, and no flipping is done. A feature claim that agrees with the engine confirms that answer, so that feature is never a suspect. Ticket C shows why this matters. Flipoutside_window from 0.95 to 0 and C scores 0.730, which is grant. Flip defective from 0.05 to 1 and it also scores 0.730. Without more information, blame would split between those two features. But both answers were right: the request was outside the window, and nothing was defective. The owner said so in their corrected feedback on ticket C, from Feedback:
grant. No single feature explains C. A case like that is a missing criterion: the spec lacks a feature it needs.
Rewrite or add
Across all the wrong cases, the writer compares the most-blamed feature’s total blame with the number of missing-criterion cases. If the blame is at least as large, it proposes a rewrite of that feature; otherwise it proposes a new feature. The owner confirmed the same two answers on each carrier-delay ticket they judged, so those are all missing-criterion cases. They outnumber the blame on any single feature (a correction like ticket D’s in Review blamesitem_used for one case), so the writer adds a feature.
A rewrite also sees missing-criterion cases and their reasons as context, not as failures attributed to the rewritten feature. Blamed cases get priority within the shared cap of 20 wrong cases.
A language model drafts the words: new wording for the blamed feature’s prompt, or a name and prompt for the new feature. It sees the base spec and the text of the wrong and right cases it may see, with their reasons and corrections. Moonbounce, not the model, builds the diff, checks that the candidate is a valid spec with a new structure hash, and replays each draft on the seed and training cases. The draft that fixes the most cases net of breaks, if any fixes more than it breaks, gets a full preview that includes the held-out cases, and it is proposed only if that preview passes the propose rule.
The Northpine suggestion
GET /v1/suggestions/{suggestion_id} returns the suggestion. Abbreviated: the full response also has candidate_spec, each version’s engine_profile, the generator that wrote it, status_at, and the evidence counted by feedback source (by_source), and changed_items lists all five fixes, not only ticket C’s.
/nodes/. add creates a node or appends a term (/terms/-); replace and remove carry a before, the value they expect to find, so an edit made against a spec that has since changed fails instead of overwriting your work. A rewrite looks like {"op": "replace", "path": "/nodes/item_used/prompt", "before": "...", "after": "..."}.
environment is the environment of the key that created the suggestion. The whole loop runs in one environment: a suggestion learns from that environment’s cases, and only another key in the same environment can read it. Only a production suggestion can be accepted, so the developer’s agent asks with a production key. requires lists suggestions that must land first; it is empty today. credited_feedback lists the feedback the writer saw that decided a case the candidate fixed. The evidence fields are explained under Previews.
Weights for a new feature
The candidate’sweights_pin is the weights pin to write in your decision file, and candidate.weights is what that pin resolves to. Northpine pins "default", so the suggestion keeps "default", and "default" resolves to the candidate spec’s own default weights. Those are recomputed for six terms: each weight is now about 0.167 instead of 0.2, and the bias is 4/6 (about 0.667) instead of 0.8. With carrier_delay answered 0.97 for C, 0.02 for A and 0.01 for B:
C crosses the grant cut at 0.60. The other fixes are the same story: the tent seed example, two other carrier-delay tickets in training, and one held-out case.
Look at A too. Under default weights every term counts equally, so a sixth feature that is “no” for most tickets lowers their scores by about a sixth: A falls from 0.766 to 0.642. It’s still
grant, but closer to the line. A change like this can break cases that sit just above a cut, which is why the preview counts breaks and replays recent traffic in the action mix.
If your file pins a weights hash instead, a rewrite keeps that hash. A new feature under a pinned hash would get no weight at all, so the suggestion ships a new pin: carried weights, which keep every existing weight and the bias and give the new term the average size of the others. A pin equal to your spec’s default weights hash counts as "default".
Previews
POST /v1/decisions/{decision_id}/previews replays a candidate spec and its base over the decision’s past cases and reports what would change. It takes the spec as it is in your code, the weights pin as written, and your cut points; with a decision file, jq builds the body:
- The base. The suggestion’s base when you send
suggestion_id; else thebaseyou send ({"structure", "weights"}); else the version serving primary traffic in your key’s environment; else the latest version seeded there. With none,baseisnulland nothing changes. - The cases. Past evaluations in your key’s environment with stored content whose right outcome was decided by you, by reviewers or by a seed example, and is one of your request’s outcomes. Your app’s own feedback never decide a case. Each item group counts once, newest first; contested cases are left out.
- The cut points. Both versions are cut at the cut points you send, never at the ones each evaluation used, so the only difference between before and after is the spec and weights.
- A fair baseline. A feature the candidate changed is asked again for the base in the same preview, so both sides carry the engine’s current noise.
noise_rateis how often that fresh base answer gave a different outcome from the stored one: the noise floor. It is 0 when no feature changed, as for a pure addition. Because the changed feature is asked afresh each time, itsbeforeanswer and score can move a little from one preview to the next. Features the candidate leaves alone replay cached answers when there are any; a test key’s preview keeps no cache between runs, so there every feature is asked afresh.
base and candidate (each {structure, weights, engine_profile}), hashes_match (with suggestion_id: whether your spec and pin hash to that suggestion’s candidate), evidence, and cost (engine calls, cache hits, input tokens and usd_micros, in millionths of a dollar). In evidence:
by_splitcounts, per set of cases,n,fixes(wrong before, right after),breaks(right before, wrong after), and how many each version got right. A case the engines failed on temporarily is left out; a version that fails a case permanently counts as wrong on it.action_mixreplays a sample of recent primary traffic in your key’s environment (theonfield says which) under both versions and gives the share of each outcome. Northpine’s grant share would rise from 16% to 30% as carrier-delay tickets stop escalating, and its deny share would edge up from 22% to 26% as other scores drop a little.changed_itemslists every fix and break by ids, scores and answers, never content, and burns any held-out case it lists.
POST /v1/decisions/{decision_id}/structures, or send the spec inline on one evaluate call.
A preview writes a record of the run and nothing else: no evaluation, no traffic, and never an acceptance.
Changing cut points in the same change
A suggestion leaves your cut points alone, but a preview takes whatever cut points you send, so you can test a structure change and a cut-point change together. Preview Northpine’s candidate with the grant cut at 0.65 instead of 0.60, and both versions are cut there: A (0.766 before, 0.642 after) breaks fromgrant to escalate, and C (0.612 after) stays on escalate. That preview would show the new feature doesn’t pay off at a stricter cut, and the action mix at those cut points shows what recent traffic would get. Cut points mint no version, so the hashes still match the suggestion.
Evidence labels
Every preview gets a label for the strongest set of cases it was tested on.
About 30% of reviewed cases are held out. An item’s side is fixed by the first feedback it gets (near-duplicate items share one side): a seed example is always a seed, your app’s own feedback put it in training, and feedback from you or a reviewer puts it in the holdout about 30% of the time, decided by a hash, never by the outcome or the answers. Held-out content never reaches the suggestion writer.
Two caveats sit in the numbers.
owner_decided counts held-out cases you decided yourself: never seen by the writer is not never seen by you. And holdout_uses counts the previews that have scored the holdout; a holdout reused many times is less of an independent test.
Shown cases become training
A held-out case listed in a preview’schanged_items has been shown to you or your agent, so it is burned: it counts as a training case from then on. Previewing the Northpine suggestion again after it was proposed gives:
The propose rule
A suggestion is proposed only when its candidate fixes more cases than it breaks on its own label’s set: the seed examples for Starting examples, the training cases for Training cases. On Held out it must also beat the noise floor: fixes minus breaks must be greater thannoise_rate times the number of held-out cases. Northpine’s suggestion passed on one held-out fix, no breaks and a noise floor of 0. When the best candidate fails the rule, nothing is proposed and no held-out case is burned.
Applying a suggestion
Your coding agent applies it. Over MCP (Connect your agent),apply_suggestion with the suggestion id returns step-by-step instructions, the diff and the candidate spec. The agent:
1
Finds the decision file
Usually
moonbounce/refund-exception.json; the spec may also be inline in code.2
Checks the base
If the local spec’s structure hash equals the suggestion’s base, it replaces the spec with the candidate. If not, it applies only the diff entries whose
before still matches, and tells you which it skipped.3
Sets the weights pin
To
weights_pin: here it stays "default", which already resolves to the candidate’s weights. Writing the candidate’s weights hash instead fails with WEIGHTS_UNKNOWN until that structure has been registered.4
Runs the preview
With
suggestion_id. hashes_match must be {"structure": true, "weights": true} when every entry applied.5
Opens a pull request
On a branch, with your own git credentials, carrying the summary, the label the suggestion was proposed with, and the preview’s numbers. It then marks the suggestion
pulled.Statuses and on-demand suggestions
A suggestion isproposed when made. POST /v1/suggestions/{suggestion_id}/status with {"status": "pulled"} marks it picked up; {"status": "rejected", "reason": "..."} turns it down (reason only goes with rejected). Proposed can become pulled, and proposed or pulled can become rejected; repeating the current status changes nothing, and anything else is 409. accepted is never set by you. GET /v1/decisions/{decision_id}/suggestions lists a decision’s suggestions newest first, filtered by status and paged with limit (up to 100) and cursor.
A suggestion read (get or list) carries stale: { "reason": "...", "cases": 2 } when feedback on cases it was evaluated on changed after its evidence was read: a reviewer changed or withdrew an answer, you corrected or judged one again, another reviewer answered one, or new feedback marked one’s input as broken. Its evidence is never rescored; preview it again for current numbers.
POST /v1/decisions/{decision_id}/suggestions asks for one now. The body may be empty: the base is then the version serving primary traffic in your key’s environment (else the latest version seeded there), at the cut points its latest evaluation recorded. You can send spec and weights to start from your local version (the spec must already be registered, or it is 404 VERSION_UNKNOWN), outcomes to set the cut points (400 OUTCOMES_INVALID when none are known), and kind to generate that kind even when blame points to the other. A forced new_feature uses missing-criterion cases when there are any, otherwise all visible wrong cases within the prompt cap. A forced feature_rewrite rewrites the top suspect, or returns no suggestion when there is no suspect. The replay and propose rules still decide whether the candidate is proposed. The answer is {"suggestion": ..., "reason": ...}:
suggestion: nullwith areasonwhen there is nothing worth proposing yet: no cases to learn from, the spec already gets them right, there is no suspect to rewrite or no combine for a new feature to join, or no candidate passed the propose rule.- The existing suggestion, instead of a duplicate, when the same candidate was proposed before.
- 404 when the decision has no evaluations or seeds to learn from.
- 503
ENGINE_UNAVAILABLEwithRetry-Afterwhen no suggestion writer is configured, the writer failed, or the engines could not score the cases. Retry with the sameIdempotency-Key.
Acceptance
A suggestion is accepted when its candidate pair (structure hash and weights hash) first serves primary production traffic,POST /v1/evaluate on a production key, after the suggestion was made. Previews, seeds and test keys never count. A pair that already served production before the suggestion existed is a revert, not an acceptance. Only proposed or pulled suggestions made with a production key can be accepted; a test key’s suggestions never are. It doesn’t matter how the change reached your code.
Moonbounce records acceptance when you next read the suggestion, the list or the loop status with a production key. GET /v1/decisions/{decision_id}/loop shows where the loop stands:
accepted carries the label each suggestion was proposed with and whether that evidence was a held-out win. reviews counts live feedback from you and reviewers. random_lane and spot_check report the blind spot checks from Review: how often they agreed with Moonbounce, on the version that served production before the first acceptance and on accepted versions. They read each reviewer’s first, blind answer; changed_after_reveal counts the spot checks whose answer was changed afterwards. stop_check is a prompt to rethink: after threshold reviews with no suggestion that won on held-out cases, more reviews of the same decision are unlikely to help.
Next
Node types and patterns lists every node type, including the ones coming soon, and shows how groupingcarrier_delay with defective would have helped ticket C while diluting ticket A less.