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

# Models and environments

> Which model answers your questions and how to change it, which key can call which operation, and how test traffic stays apart from production.

The guides on building, running and improving a decision follow one decision from structure to accepted suggestion ([How Moonbounce works](https://docs.moonbounce.ai/learn/how-it-works.md)). This page covers the setup around it: the model that answers the questions, the API keys that call the operations, the production and test environments, and the limits, usage and data handling that come with them.

## Models

A **model** answers your questions. For each `noul` question it returns its probability that the answer is true, and that is all it does. Each question is sent as you wrote it, over the part of the state it reads, and questions that read the same keys share a call. Everything after the answers, every aggregation and outcome, is computed by Moonbounce and is deterministic: the same answers always give the same value and outcome.

Two models are offered:

* **`jev-1.14.0`** is the default. It is pinned: the id always serves the same model.
* **`@cf/cloudflare/clef`** is Cloudflare's open-weights decision model, served on Workers AI. It is an alias, not pinned: Cloudflare can change what it serves, so answers can drift without the id changing. It asks each question in its own call and lists a higher price per token, so expect it to cost more per evaluation than `jev-1.14.0`.

`GET /v1/models` lists what this deployment offers. Any scoped key can call it. Trimmed of `pricing`:

```json theme={null}
{
  "default": "jev-1.14.0",
  "models": [
    { "id": "jev-1.14.0", "resolved_model": "jev-1.14.0", "pinned": true, "question_types": ["noul"], "modalities": { "text": "ga", "json": "ga", "image": "preview" } },
    { "id": "@cf/cloudflare/clef", "resolved_model": "@cf/cloudflare/clef@clef@0.1.0", "pinned": false, "question_types": ["noul"], "modalities": { "text": "ga", "json": "ga", "image": "preview" } }
  ]
}
```

`default` is the model a decision runs on when nothing else is set. `id` is what you pass to `PUT /v1/decisions/{decision_id}/model`. `resolved_model` is what evaluations report as the model that answered, `question_types` are the question types it answers (`noul` today), and `modalities` are the content types it reads (`preview` ones are not used by evaluate yet). `pricing` gives the list price per million input and output tokens and its `source`.

### A decision's model

Each decision has one model in each environment. The model in effect is that environment's own stored setting, else the deployment default. The first time a structure is registered for a decision in an environment (an evaluate or run that isn't a dry run, an import of examples, or `POST /v1/decisions`), the default is stored there as revision 1, so a later change of the default never moves a decision on its own.

`GET /v1/decisions/{decision_id}/model` returns the model in effect for your key's environment. It answers for any decision that exists. Before anything is stored there it reports the deployment default, with `revision` null and `source` `default`; once a setting is stored, `source` is `setting`. It is 404 `DECISION_UNKNOWN` only when the decision doesn't exist:

```json theme={null}
{
  "decision_id": "refund-exception",
  "environment": "test",
  "model": "jev-1.14.0",
  "pinned": true,
  "revision": 1,
  "source": "setting",
  "created_at": "2026-10-20T09:14:03.551Z"
}
```

### Changing the model

`PUT /v1/decisions/{decision_id}/model` sets the model for your key's environment:

```bash theme={null}
curl -sS --fail-with-body -X PUT "$MOONBOUNCE_API_URL/v1/decisions/refund-exception/model" \
  -H "Authorization: Bearer $MOONBOUNCE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"model": "@cf/cloudflare/clef"}'
```

* It always answers **200** with the setting and `"changed": true` when it wrote a new revision. Sending the model that is already in effect writes nothing and answers `"changed": false`.
* A model the deployment doesn't offer, or a floating alias such as `jev-latest` or a `-preview` id, is 400 `CAPABILITY_UNSUPPORTED`.
* The new model applies from the next call: evaluations, runs, imported examples, previews and suggestions all use it.
* Changing back is another revision: setting `jev-1.14.0` again makes revision 3, not a return to revision 1.

The old `GET .../binding` and `POST .../binding/revisions` routes answer 410 `ENDPOINT_REMOVED`, and `details.replacement` names the new one.

Every evaluation says what answered it. `model.id` is the setting's model, `model.resolved_model` is what actually answered, `model.revision` is the setting's revision (null when nothing was stored and the default applied), and `model.profile` is an opaque string for the models and connectors behind the questions. Compare profiles for equality to know two runs are comparable; never parse one:

```json theme={null}
{
  "version": { "structure_hash": "sha256:4b0c…", "number": 1 },
  "model": {
    "id": "@cf/cloudflare/clef",
    "resolved_model": "@cf/cloudflare/clef@clef@0.1.0",
    "revision": 2,
    "profile": "defective=clef:@cf/cloudflare/clef@clef@0.1.0,item_used=clef:@cf/cloudflare/clef@clef@0.1.0"
  }
}
```

There is no automatic failover: when the model can't answer a question the result needs, the call fails with 502 or 503 rather than asking another model.

### The model and your version

The model is not part of the structure hash. Your structure says what to ask, the model setting says who answers, and the evaluation records both. Changing the model mints no new structure version, so your code and your hashes stay as they are, and a change moves all of a decision's traffic at once.

The weights, thresholds and cut points in your structure were chosen against one model's answers. A sharper or more hedging model can move items across a cut, so check a change before production depends on it.

### Try a change on test first

The model changes every answer, so check a change before production depends on it. Previews run on the model in effect in your key's environment: a test key uses test's own setting, never production's, which makes test a safe place to try. A test key's previews replay test cases only, so the comparison runs on examples you send to test:

**1. Import examples in test**

With a test key, import your labeled examples (`POST /v1/decisions/{decision_id}/examples`, or `mb import examples.jsonl`; see [Import examples](https://docs.moonbounce.ai/learn/feedback.md#import-examples)). That registers the structure, so the test environment stores its own model setting, and gives test previews cases to replay.

**2. Preview on the current model**

Run a preview of your current decision from the test key and note `candidate_correct` in `by_split`: how many of those cases `jev-1.14.0` gets right ([Previews](https://docs.moonbounce.ai/learn/improving.md#previews)).

**3. Change the test model**

Send `{"model": "@cf/cloudflare/clef"}` to `PUT .../model` with the test key.

**4. Preview again**

The same preview now replays the same cases on Clef. Compare the counts, and check `cost`.

**5. Change production**

If Clef does as well, send the same `PUT` with a production key that has `decisions:develop`.

## Keys

### Getting access

Join the waitlist at [app.moonbounce.ai](https://app.moonbounce.ai). Once you're approved, sign in and create your team. Team admins create and revoke keys at `/team/keys`; other members see the list read-only. The full key is shown once, when it is created.

A key belongs to one team and one environment, Production or Test, chosen when it is created. Neither ever changes, and neither do its scopes: to change any of them, create a new key and revoke the old one.

### Scopes

Each operation needs one scope, listed in its `security` in the OpenAPI document. A key without it gets 403 `INSUFFICIENT_SCOPE`, with the scope it needs in `details.required`. A key with no scope is refused everywhere.

| Scope | Operations |
| - | - |
| `decisions:evaluate` | `evaluate`, `createRun` (with `cases`, `decisions:develop` too), `getEvaluation`, `listEvaluations`, `getDecision`, `listDecisions`, `listVersions`, `getVersion` |
| `decisions:feedback` | `submitFeedback` from your app (`app_native`, `app_resolution`), `getFeedback` |
| `decisions:develop` | `getDecision`, `listDecisions`, `listVersions`, `getVersion`, `registerDecision`, `importExamples`, `listCases`, the feedback export (`listFeedback`), the review queue, suggestions, previews, the loop status, `lossReport`, `sweepCut`, and the settings (`getModel`, `setModel`, `getObjective`, `setObjective`); also `submitFeedback` and `getFeedback`, including owner feedback and replacing any submission that isn't one of your app's feedback |
| Any scope | `whoami`, `listModels`, `getUsage` |
| No key | `validateDecision` |

`GET /health` and `POST /v1/validate` need no key. MCP tools need the same scope as the operation they call.

A setup that works for most teams:

* **Your app's backend:** a Production key with `decisions:evaluate`, plus `decisions:feedback` if it reports outcomes back.
* **You and your coding agent:** a Production key with `decisions:develop`, so suggestions and previews learn from your real feedback. Add `decisions:evaluate` if the agent should evaluate items itself. Owner feedback, which decides a case's right answer, needs `decisions:develop`, so it comes from this key, not from the app's.
* **Trying things out:** a Test key with all three scopes, as in the [quickstart](https://docs.moonbounce.ai/quickstart.md). Its evaluations, feedback, previews and suggestions stay in test.

### Lifetime, rotation and revocation

Keys don't expire. To rotate one without downtime, create the new key, deploy it, confirm in your own logs that traffic moved, then revoke the old one. A team can hold up to 20 live keys at a time. A revoked key stops working within about a minute everywhere.

## Your account

Your own settings are on [Account](https://app.moonbounce.ai/account): open it from Account at the top of any page. It holds your name, your passkeys, the devices you're signed in on and the agents connected as you.

* **Passkeys.** The passkeys you sign in with, when passkey sign-in is on. Remove one with a code sent to your email. If it's your only passkey, you sign in another way afterwards, such as a code sent to your email.
* **Signed-in devices.** Sign out one device, or every device but this one. Connected agents stay connected.
* **Connected agents.** Agents you signed in through MCP ([Connect your agent](https://docs.moonbounce.ai/connect-your-agent.md)). Each acts as you in the team you picked when you connected it. **Disconnect** removes its access to all your teams within a few minutes; to use it again, connect it again.

To change your sign-in email or delete your account, email support from the link on the page.

## Environments

Every tenant success response includes `environment` (`production` or `test`), taken from the calling key. Call `GET /v1/whoami` (the `whoami` MCP tool) first to see your organization, environment, key id, actor and scopes. Any key with at least one scope can call it; it reads no data and spends nothing.

Production and test are separate inside your team. Each of these belongs to one environment, the environment of the key that made it, and is invisible from the other:

* **Evaluations.** Reading another environment's evaluation is a 404.
* **Feedback.** `about` resolves only to evaluations in your key's environment, by `evaluation_id` or by `item_id`.
* **The review queue.** A test key's queue holds test traffic. The Review page in the web app shows production.
* **Idempotency keys.** The same `Idempotency-Key` in test and production names two different requests.
* **The model setting.** Changing test's model leaves production's alone, and the other way round. A test key's evaluations, previews, suggestions and runs use test's own setting, else the deployment default; they never fall back to production's.
* **The improvement loop.** Previews and suggestions learn only from the key's environment: its reviewed and imported cases, its base version, its action mix and its cases' splits. The same item or text in test and in production is two separate cases. Suggestions, and the loop status, are listed and read only in the environment that made them; another environment's `suggestion_id` is a 404.

Test traffic never counts as production traffic. Only primary production traffic accepts a suggestion, so a test key's suggestions are never accepted, and a test key's loop status lists no acceptances. To check a change against your real feedback, preview it with a production key.

Structures registered by hash are shared across both environments, because they are configuration, not traffic.

## Rate limits

Two limits apply to `/v1` and `/mcp`, each counted per calendar minute across every location: 600 requests a minute per key, and 300 a minute per client IP for requests that carry no valid credential (no key, a malformed or unknown one, or the public hash route). A valid key rarely counts against the per-IP limit: when it is first validated, and again when its cached validation expires after a day without one; while an IP is over its limit, though, even valid keys from it can be refused until the minute ends. Each MCP request counts once against the key and each tool call in it once more, like the REST call it makes, and one MCP request may carry at most 10 messages. A 429 `RATE_LIMITED` says which limit in `details.limit` (`key_rate` or `ip_rate`) and how long to wait in `Retry-After` and `details.retry_after_seconds`; a 503 from the limiter itself also carries `Retry-After`.

The calls that spend model time (`POST /v1/evaluate`, runs, imported examples, previews and suggestions) also count against limits for your whole team, across all its keys and both environments:

* **A rate and a cap on calls in flight.** Over either, the call is 429 `RATE_LIMITED` with `Retry-After`, and `details.limit` is `org_rate` or `org_concurrency`.
* **A daily work budget, per UTC day.** Each evaluation and each imported example costs one unit. A preview is charged its largest possible size up front, currently 600 units however many cases it replays, and a suggestion 700, which covers writing it and its preview. When the budget is used up, the call is 429 `QUOTA_EXCEEDED` with no `Retry-After`: `details.reset_at` says when the budget resets. A single request bigger than the whole budget is 422 `REQUEST_TOO_LARGE`, with `details.too_large: true` and no reset time or `Retry-After`; split it into smaller requests with fresh idempotency keys.

These team limits are checked after the `Idempotency-Key` lookup and after validation, so a retry that replays a stored result, or a request refused as invalid, doesn't use them. Two identical requests sent at the same moment can both be charged, except evaluate, import or preview requests with the same `Idempotency-Key`: the second waits for the first's result and is charged only if it runs after all, because the first failed or stopped responding. A preview is charged when it starts: a keyed preview that runs past its 60 seconds answers a retryable 503 (`ENGINE_UNAVAILABLE` when its replays ran out of time, `STORE_UNAVAILABLE` when its run could not be saved in time), saves nothing and keeps its charge. Retry it with the same key; the retry is charged again only if it runs ([Previews](https://docs.moonbounce.ai/learn/improving.md#previews)). The per-key limit counts every keyed request, replays and refusals included, and the per-IP limit every request it applies to (see above).

Over a `RATE_LIMITED` limit, wait `Retry-After` seconds, then retry with the same `Idempotency-Key`. Over the daily budget, retrying before `reset_at` won't help. MCP tool calls don't see headers: every `RATE_LIMITED` error carries the wait as `details.retry_after_seconds` too.

## Usage

Evaluate responses (including dry runs), evaluation reads, and each imported example's result report one `cost` shape, the same on previews and runs: `{model_calls, questions, cache_hits, input_tokens, output_tokens, usd_micros}`, and on evaluate also `calls`, what each model call sent. An import's top-level `cost` totals those fields across its results. `usd_micros` is millionths of a US dollar; evaluate and import amounts come from the same usage rows as the report below, rounded per evaluation, and are reported cost only: the report adds an estimate for calls that reported no usage and breaks it out as `estimated_usd_micros`. `model_calls` counts upstream calls to a model; one call can answer several questions (those that read the same keys share it). `questions` counts the questions sent to a model. Retries count too. `cache_hits` is present on previews and case runs, and on evaluate only when the answer cache served a question.

Fresh evaluate and import responses always carry `cost`. Evaluation reads omit `cost` if no usage row exists. Replayed costs describe the original request and do not represent new spend.

`GET /v1/decisions/{decision_id}/usage` reports what a decision's model calls cost, for your key's environment, over a window: `since` (inclusive) and `until` (exclusive), by default the last 30 days, at most 366 days. Any scoped key can read it. Amounts are in millionths of a US dollar:

```json theme={null}
{
  "decision_id": "refund-exception",
  "environment": "production",
  "window": { "since": "2026-09-27T18:00:00.000Z", "until": "2026-10-27T18:00:00.000Z" },
  "evaluations": [
    {
      "model": { "profile": "defective=jev:jev-1.14.0@jev@0.1.0,item_used=jev:jev-1.14.0@jev@0.1.0" },
      "assignment_mode": "primary",
      "evaluations": 50,
      "uncached": 50,
      "model_calls": 50,
      "input_tokens": 30000,
      "output_tokens": 0,
      "usd_micros": { "total": 1260, "mean": 25.2, "p50": 25, "p95": 29 },
      "estimated_usd_micros": 0
    }
  ],
  "models": [
    { "resolved_model": "jev-1.14.0", "adapter": "jev@0.1.0", "calls": 50, "failed": 0, "input_tokens": 30000, "output_tokens": 0, "usd_micros": 1260, "estimated_usd_micros": 0 }
  ],
  "other_spend": [
    { "kind": "uncommitted", "attempts": 2, "model_calls": 2, "input_tokens": 1200, "output_tokens": 0, "model_input_tokens": 0, "model_output_tokens": 0, "usd_micros": 50, "estimated_usd_micros": 0 }
  ],
  "total": { "usd_micros": 2192, "estimated_usd_micros": 0 },
  "previews": { "runs": 3, "questions": 210, "input_tokens": 21000, "usd_micros": 882, "estimated_usd_micros": 0 }
}
```

Dry runs store usage without an evaluation, so their totals appear under an `unknown` model profile and assignment mode; they contribute no per-model traces. They count against the same team admission limits as ordinary evaluations.

`evaluations` groups by `model.profile` and assignment mode (`primary` for evaluate, `example` for imported examples), with the cost per evaluation as total, mean, median and 95th percentile. `models` groups the calls by resolved model and adapter, retries and failures included. `previews` covers preview runs, which write no evaluations. The key page shows each key's spend over the last 30 days next to its last use.

Costs are what the models reported, priced at each model's list price. A call that may have been billed but reported nothing (abandoned at a deadline, or a dropped connection) has its input cost estimated and shown apart as `estimated_usd_micros`. Evaluations and preview runs that never committed (a failed evaluate or preview, an imported example that failed, the loser of an idempotency race) and suggestion generation (the writer's language model call and its exploratory replays) appear in `other_spend`, outside the per-evaluation numbers, and `total` adds everything recorded. The key page shows the same split per key: evaluations, previews and other spend, with any estimated part.

## Your content

* **It is stored.** The `state`, `attribute_values` and `moonbounce.summary` you send are stored with the evaluation, so your team can review the case and previews can replay it. There is no API to delete evaluations yet, so don't send content you can't have stored.
* **The model sees it.** Every question is answered by the decision's model, from the part of the state it reads. Attribute values and `moonbounce.metadata` never reach the model.
* **The suggestion writer sees some of it.** A language model reads the text of imported examples and reviewed cases when it drafts a suggestion. Suggestions carry structure text and ids, and preview results carry ids, values and answers, never content.

## Next

[Connect your agent](https://docs.moonbounce.ai/connect-your-agent.md) sets up your coding agent with these operations as MCP tools, and the [Glossary](https://docs.moonbounce.ai/learn/glossary.md) collects every term from these guides.


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