# Antistatic REST API

The REST API behind antistatic.exchange, its MCP server and the antistatic CLI. JSON in and out; most successful responses wrap their payload in `data`, and errors are `{"error": {"message", "code"}}`. Additive changes (new routes, new response fields, new optional parameters) can happen at any time, so ignore fields you don't know. A breaking change gets a new path or a deprecation window first: deprecated routes send a `Deprecation` header (RFC 9745), with `Sunset` and a `Link` when known.

Base URL: https://antistatic.exchange/api/v1. Machine-readable contract: https://antistatic.exchange/api/v1/contract.json.

Most agents don't need the raw API: chat apps use the MCP connector (https://antistatic.exchange/mcp) and coding agents the CLI (https://github.com/finnhambly/antistatic-cli); see https://antistatic.exchange/skill.md.

## Authentication

Authorization: Bearer <token>: an OAuth access token (`antistatic login`, MCP sign-in) or a personal API token from https://antistatic.exchange/users/settings#api-tokens. Each route below names what it needs:

- `none`: Public; a token is ignored.
- `optional`: Public; with a token the response adds your own data (and a token needs the read scope).
- `read`: A token with the read scope.
- `write`: A token with the write scope.
- `comment`: A token with the comment scope.
- `admin`: A token with the admin scope, held by an admin.

Scripts and CI that can't sign in through a browser can use a personal token: create one in Settings (https://antistatic.exchange/users/settings#api-tokens).

## Headers

- `user-agent`: Clients identify themselves, e.g. `antistatic-cli/1.2.3`.
- `x-antistatic-as`: `bot`: forecast or comment as your bot account (routes that list this header).
- `x-antistatic-run`: A sandbox run id: read the market inside your run, as of its clock (routes that list this header; also `?run_id=`). On a wargame's questions, quote and forecast too (POST routes that list it); a past sandbox is read-only (403 `not_allowed_in_run`).

## Errors

Errors are `{"error": {"message": "...", "code": "..."}}`: 401 `unauthorized` (no token) or `invalid_token` (a token sent and refused: expired, revoked or unknown, as its message and `WWW-Authenticate` say), 403 `forbidden` (missing scope), 404 `not_found`, 422 for invalid input (`invalid_params`, `out_of_order` and others, sometimes with details), 429 `rate_limited`.

## Rate limits

Per account with a token, per IP without; read and write allowances are separate. Responses carry x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-scope; a 429 carries retry-after.

| Request identity | Read allowance | Write allowance |
| --- | --- | --- |
| Authenticated account | 1200 per 60 seconds | 180 per 60 seconds |
| Unauthenticated IP address | 120 per 60 seconds | 30 per 60 seconds |

All tokens of one account share its allowance. The limiter runs per application instance.

## Markets

### GET /api/v1/markets

Markets you can see, with search. Auth: public; more with a token.

Markets you can see: public ones, plus with a token your private markets and those shared with you. `q` searches open markets (`include_closed=true` widens it); `open=true` lists open markets; `unforecasted=true` (signed in, not with `q`) lists open markets you haven't forecast on. Pages with `limit`/`offset`; `paging` says whether there are more.

Query: `include_closed`, `limit`, `offset`, `open`, `q`, `unforecasted`.

Example query: `?limit=1&open=true`

Response 200:

```json
{
  "data": [
    {
      "budget": 100.0,
      "code": "test-api",
      "cumulative": true,
      "end_date": "2027-10-10T19:18:00Z",
      "id": 5129,
      "inserted_at": "2026-10-10T19:18:00Z",
      "local_code": "test-api",
      "reference": "test-api",
      "resolution_criteria": "Test criteria",
      "status": "open",
      "title": "Test Market",
      "type": "count",
      "visibility": "public",
      "x_unit": "units"
    }
  ],
  "paging": {
    "has_more": false,
    "limit": 1,
    "next_offset": null,
    "offset": 0
  }
}
```

### GET /api/v1/markets/{code}

One market's details. Auth: public; more with a token. Accepts `x-antistatic-run`.

`fuzzy=true` resolves a near-miss code. With `X-Antistatic-Run`, the market as it stands in your sandbox run.

Query: `fuzzy`, `run_id`.

Response 200:

```json
{
  "data": {
    "background_info_html": null,
    "budget": 100.0,
    "code": "~5229",
    "cumulative": true,
    "end_date": "2027-10-10T19:18:09Z",
    "id": 5229,
    "inserted_at": "2026-10-10T19:18:09Z",
    "local_code": "share-19559",
    "reference": "~5229",
    "resolution_criteria": "Criteria",
    "status": "open",
    "title": "Shared market",
    "type": "count",
    "visibility": "private",
    "x_unit": "units"
  }
}
```

### GET /api/v1/markets/{code}/submarkets

A market's bars (submarkets) with liquidity, resolution state and curve summaries. Auth: public; more with a token. Accepts `x-antistatic-run`.

Query: `run_id`.

Response 200:

```json
{
  "curves": [
    {
      "at_least": null,
      "below": null,
      "curve": "group1 2027-10-10",
      "end_date": "2027-10-10T19:18:12Z",
      "outcome": null,
      "projection_group": "group1",
      "resolved_submarkets": 0,
      "resolved_value": null,
      "status": "open",
      "submarkets": 1
    }
  ],
  "data": [
    {
      "budget_allocation": 50.0,
      "curve": "group1 2027-10-10",
      "effective_b": 50.0,
      "end_date": "2027-10-10T19:18:12Z",
      "id": 37516,
      "initial_probability": 0.5,
      "label": "Test submarket",
      "projection_group": "group1",
      "status": "open",
      "submarket": "sm_37516",
      "threshold_date": null,
      "threshold_decimal": 100.0
    }
  ]
}
```

### GET /api/v1/markets/{code}/forecast

A market's bars grouped by curve, with the house line, the crowd and your forecast. Auth: public; more with a token. Accepts `x-antistatic-run` and `x-antistatic-as`.

- `starting_probability` is the house line forecasts are priced against: quote it as the market's number. `community_probability` is the crowd (`contributors` users). `my_probability` is your own forecast, or null (only with a `read` token).
- `status` is `open`, `closed` or `resolved`. A count period past its end that isn't resolved stays `open` and takes forecasts with nothing at stake. Resolved rows carry `resolved_yes` and `resolved_at`; don't quote them as live odds.
- `curve` names the curve a bar belongs to; match it against `curves[].curve`, whose `outcome` describes what has resolved.
- Filter with `year`/`group`/`curve`, read one bar with `for` (`for=2026`, `for=jun 2026`, `for=1000`), and `limit=0` returns every row of a large market. Large markets default to `response_mode: "summary_index"`, whose `groups` summarise each curve; `mode=full` returns rows.
- With `X-Antistatic-Run`, the market as it stands in your sandbox run.

Query: `curve`, `for`, `group`, `include`, `limit`, `mode`, `offset`, `run_id`, `year`.

Response 200:

```json
{
  "data": {
    "chart_color": null,
    "code": "t48194",
    "curves": [],
    "forecast": {},
    "forecast_by_group": {},
    "group_scope": "cumulative",
    "local_code": "t48194",
    "prob_direction": "increasing",
    "reference": "t48194",
    "response_mode": "grouped_forecast",
    "status": "open",
    "submarkets": [],
    "title": "some title",
    "type": "date",
    "visibility": "public"
  }
}
```

## Forecasting

### GET /api/v1/markets/{code}/quote

Quote a forecast: its cost and what it wins or loses. Changes nothing. Auth: read scope. Accepts `x-antistatic-run` and `x-antistatic-as`.

Name a bar with `submarket` (`sm_42`) or `submarket_id` and the probability with `to_p`; `from_p` defaults to your current forecast, or the starting probability. For several bars send `updates` (a JSON array as for positions). `cost` is points paid; `profit_if_yes`/`profit_if_no` what the change wins or loses; `draft_url` opens it on the market page as an unsaved draft.

Query: `from_p`, `submarket`, `submarket_id`, `to_p`, `updates`.

Example query: `?from_p=0.5&submarket=sm_45612&to_p=0.65`

Response 200:

```json
{
  "data": {
    "b_used": 49.9999984145104,
    "cost": 17.833746631432213,
    "draft_url": "http://localhost:4002/m/trade-test?g=group1&draft=eyJ1IjpbWzQ1NjEyLDAuNjVdXX0",
    "points_cashed_out": 0.0,
    "points_invested": 17.833746631432213,
    "points_won_lost_if_no": -17.833746631432213,
    "points_won_lost_if_yes": 13.118212807398741,
    "shares_yes": 30.951959438830954
  }
}
```

### POST /api/v1/markets/{code}/curve-targets

The bar updates that targets or a shift would make. Changes nothing. Auth: public; more with a token. Accepts `x-antistatic-run` and `x-antistatic-as`.

The bar updates a `targets`/`shift` request would make, and a `draft_url`. Works without a token (curves start from the house line); with one, it needs `read` and starts from your forecast.

Request body:

```json
{
  "targets": [
    {
      "probability": 0.4,
      "threshold": 200
    }
  ]
}
```

Response 200:

```json
{
  "data": {
    "draft_url": "http://localhost:4002/m/dl71554?g=period&draft=eyJ0IjpbeyJwcm9iYWJpbGl0eSI6MC40LCJ0aHJlc2hvbGQiOjIwMH1dfQ",
    "updates": [
      {
        "probability": 0.6517,
        "submarket": "sm_44303"
      },
      {
        "probability": 0.4,
        "submarket": "sm_44304"
      }
    ]
  }
}
```

### GET /api/v1/markets/{code}/pending-edits

Your staged (unsubmitted) forecast for a market. Auth: read scope. Accepts `x-antistatic-as`.

Response 200:

```json
{
  "data": {
    "sm_39279": {
      "probability": 0.4
    }
  }
}
```

### POST /api/v1/markets/{code}/positions

Submit a forecast: bar probabilities, targets or a shift. Changes your forecast and points. Auth: write scope. Accepts `x-antistatic-run` and `x-antistatic-as`.

Send `updates` (`[{"submarket": "sm_42", "probability": 0.65}]`), `targets` and/or a `shift`. Each update moves your forecast on that bar; the cost follows the LMSR. Returns 201.

Bars on a curve stay in order: date curves don't fall from earlier to later dates, count curves don't rise with the threshold. A forecast that breaks the order is refused (422 `out_of_order`, naming the bars, with `keep_in_order_preview`) unless `rest_of_curve` is `keep_in_order` (other bars move only as far as order needs) or `interpolate` (bars between yours sit on a straight line). Your bars are kept as sent.

A target (`{"curve": ..., "threshold" or "label": ..., "probability": ...}`) sets one point exactly and the rest of its curve follows by the same log-odds change; several targets interpolate between them. A `shift` (`{"curve": ..., "quantile": 0.5, "by": "+10%"}` or `"to"`) moves a quantile. `curve` can be left out when the market has one open curve. With targets or a shift, `rest_of_curve` defaults to `keep_in_order`.

With `X-Antistatic-Run`, the forecast is on a question of your wargame run (a goal is scored this way), as of its clock.

Request body:

```json
{
  "updates": [
    {
      "probability": 0.7,
      "submarket": "sm_38885"
    }
  ]
}
```

Response 201:

```json
{
  "data": {
    "canonicalization_adjusted_count": 0,
    "results": [
      {
        "canonicalization_adjusted": false,
        "canonicalization_delta": 0.0,
        "cost": 0.0,
        "intended_probability": 0.7,
        "money_cost_delta": 0.0,
        "money_points_cashed_out": 0.0,
        "money_points_invested": 0.0,
        "money_shares_delta": 0.0,
        "requested_probability": 0.7,
        "shares_yes": 4.4183275227903925e-10,
        "status": "ok",
        "submarket": "sm_38885",
        "topup_cost": 0.0,
        "topup_shares": 0.0
      }
    ],
    "status": "ok"
  }
}
```

### PUT /api/v1/markets/{code}/pending-edits

Stage a forecast for review on the site, without submitting it. Auth: write scope. Accepts `x-antistatic-as`.

Takes the same `updates`, `targets`, `shift` and `rest_of_curve` as submitting a forecast, and leaves the change staged for you to review and submit on the market page. `mode: "replace"` replaces the staged forecast instead of merging into it.

Request body:

```json
{
  "updates": [
    {
      "probability": 0.7,
      "submarket": "sm_39053"
    }
  ]
}
```

Response 200:

```json
{
  "data": {
    "sm_39053": {
      "probability": 0.7
    }
  }
}
```

### DELETE /api/v1/markets/{code}/pending-edits

Discard your staged forecast. Auth: write scope. Accepts `x-antistatic-as`.

Response 200:

```json
{
  "data": {
    "status": "ok"
  }
}
```

## Your forecasts

### GET /api/v1/markets/{code}/positions

Your positions in one market. Auth: read scope. Accepts `x-antistatic-run` and `x-antistatic-as`.

Query: `run_id`.

Response 200:

```json
{
  "data": [
    {
      "cost": 14.384103166472112,
      "created_via": "web",
      "id": 14996,
      "market_code": "ct10915",
      "market_title": "Contract test market",
      "net_cost": 14.384103166472112,
      "net_shares": 22.09163691343073,
      "probability": 0.7,
      "shares": 22.09163691343073,
      "submarket": "sm_48370",
      "submarket_details": {
        "id": "sm_48370",
        "label": "At least 100",
        "market": {
          "code": "ct10915",
          "id": 6656,
          "title": "Contract test market"
        }
      },
      "submarket_label": "At least 100"
    }
  ],
  "paging": {
    "has_more": false,
    "limit": 100,
    "next_offset": null,
    "offset": 0
  }
}
```

### GET /api/v1/markets/{code}/points

What your positions in a market win or lose under each way it could resolve. Auth: read scope. Accepts `x-antistatic-run` and `x-antistatic-as`.

A cumulative market that forms one curve returns `scenarios`; otherwise `groups` has one entry per curve. Each scenario has a readable `resolution` (date curves: the period the event first happens; count curves: the bucket the value lands in), `points_won_lost`, and `possible: false` when resolved bars rule it out. `at=<text>` keeps scenarios whose label contains that text.

Query: `at`, `run_id`.

Response 200:

```json
{
  "data": {
    "cumulative": true,
    "market": "mcp-test",
    "scenarios": [
      {
        "hi": 100.0,
        "lo": null,
        "points_won_lost": 0.0,
        "possible": true,
        "resolution": "Below 100"
      },
      {
        "hi": 200.0,
        "lo": 100.0,
        "points_won_lost": 0.0,
        "possible": true,
        "resolution": "100 to under 200"
      }
    ],
    "title": "MCP test market",
    "type": "count",
    "unit": "points"
  }
}
```

### GET /api/v1/positions

All your positions. Auth: read scope. Accepts `x-antistatic-as`.

Query: `as`, `limit`, `market`, `market_code`, `offset`, `summary`.

Example query: `?limit=1`

Response 200:

```json
{
  "data": [
    {
      "cost": 3.449643574347566,
      "created_via": "web",
      "id": 14259,
      "market_code": "pos-b6978",
      "market_title": "Position Market B",
      "net_cost": 3.449643574347566,
      "net_shares": 8.215152564563809,
      "probability": 0.44,
      "shares": 8.215152564563809,
      "submarket": "sm_47061",
      "submarket_details": {
        "id": "sm_47061",
        "label": "B-200",
        "market": {
          "code": "pos-b6978",
          "id": 6477,
          "title": "Position Market B"
        }
      },
      "submarket_label": "B-200"
    }
  ],
  "paging": {
    "has_more": true,
    "limit": 1,
    "next_offset": 1,
    "offset": 0
  }
}
```

### GET /api/v1/profile/summary

Your points totals. Auth: read scope. Accepts `x-antistatic-as`.

Response 200:

```json
{
  "data": {
    "forecasting_points": 0.0,
    "private_forecasting_points": 0.0,
    "tipping_profit": 0,
    "user_id": 7994
  }
}
```

### GET /api/v1/profile/forecast-history

Your forecasts over time, newest first. Auth: read scope. Accepts `x-antistatic-as`.

Query: `limit`, `offset`.

Response 200:

```json
{
  "data": {
    "pagination": {
      "limit": 50,
      "offset": 0,
      "total_count": 0
    },
    "rows": [],
    "user_id": 10482
  }
}
```

### GET /api/v1/profile/liquidity-decay

Upcoming liquidity cuts on bars you hold. Auth: read scope. Accepts `x-antistatic-as`.

Query: `limit`.

Response 200:

```json
{
  "data": {
    "as_of": "2026-10-10T19:44:30.586567Z",
    "decaying_count": 0,
    "horizon_hours": 24,
    "markets": [],
    "total_count": 0,
    "user_id": 10483
  }
}
```

### GET /api/v1/profile/points-history

The points-over-time series from your profile. Auth: read scope. Accepts `x-antistatic-as`.

`range` is `1d`, `1w` (hourly), `1m`, `1y` or `all` (daily, UTC; the default). Each point has running `realized_points`, `user_expected_points`, `community_expected_points` and `tips_received` at `at`.

Query: `range`.

Example query: `?range=all`

Response 200:

```json
{
  "data": {
    "as_of": "2026-10-10T19:18:01Z",
    "cadence": "daily",
    "points": [],
    "range": "all",
    "start_at": "2026-10-10T19:18:00Z",
    "user_id": 7994
  }
}
```

## Comments

### GET /api/v1/markets/{code}/comments

A market's comments, threaded. Auth: read scope. Accepts `x-antistatic-run`.

Threaded, newest first by default (`sort`). `limit` top-level threads (max 100), `max_comments` in total, `max_body_chars` per body; page with `cursor_id`/`cursor_inserted_at` from the previous page. With `X-Antistatic-Run`, the run's own comments.

Query: `cursor_id`, `cursor_inserted_at`, `filter`, `limit`, `max_body_chars`, `max_comments`, `replies`, `run_id`, `sort`.

Example query: `?limit=20&sort=new`

Response 200:

```json
{
  "data": {
    "comments": [],
    "filter": "all",
    "paging": {
      "has_more": false,
      "limit": 20,
      "next_cursor": null
    },
    "returned_count": 0,
    "sort": "newest",
    "total_count": 0,
    "truncated": false
  }
}
```

### POST /api/v1/markets/{code}/comments

Post a comment or a reply. Auth: comment scope. Accepts `x-antistatic-as`.

Send `body` (plain text, or Markdown with `format: "markdown"`) or `body_html`; `parent_id` replies. Returns 201 with the comment. A reply notifies the parent's author; @mentions notify those named.

Request body:

```json
{
  "body": "Bot here"
}
```

Response 201:

```json
{
  "data": {
    "body_html": "Bot here",
    "body_text": "Bot here",
    "created_via": "web",
    "edited_at": null,
    "id": 1177,
    "inserted_at": "2026-10-10T19:22:20Z",
    "parent_id": null,
    "reply_count": 0,
    "score": 1,
    "total_tips": 0,
    "user": {
      "bot": true,
      "id": 8928,
      "username": "finn-bot"
    }
  }
}
```

### PUT /api/v1/markets/{code}/comments/{comment_id}

Edit your comment. Auth: comment scope. Accepts `x-antistatic-as`.

Request body:

```json
{
  "body": "Edited"
}
```

Response 200:

```json
{
  "data": {
    "body_html": "Edited",
    "body_text": "Edited",
    "created_via": "api",
    "edited_at": "2026-10-10T19:22:20Z",
    "id": 1177,
    "inserted_at": "2026-10-10T19:22:20Z",
    "parent_id": null,
    "reply_count": 0,
    "score": 1,
    "total_tips": 0,
    "user": {
      "bot": true,
      "id": 8928,
      "username": "finn-bot"
    }
  }
}
```

## Making markets

### GET /api/v1/market-recipe

The market recipe: settings, rules and examples for writing a market spec. Auth: public.

Response 200: too long to show here; see the example in the contract.

### POST /api/v1/market-specs/preview

Check a market spec without creating anything: issues with fixes, and next options. Auth: public; more with a token.

Response 200:

```json
{
  "data": {
    "curves": [],
    "issues": [
      {
        "example": "\"question\": \"when\" or \"question\": \"how_much\"",
        "field": "question",
        "fix": null,
        "message": "Say whether it asks when something happens or how much of something there is.",
        "severity": "error"
      },
      {
        "example": null,
        "field": "title",
        "fix": null,
        "message": "Give the market a title.",
        "severity": "error"
      }
    ],
    "market": null,
    "next_options": [
      {
        "default": null,
        "example": null,
        "required": true,
        "set": false,
        "setting": "question",
        "values": [
          "when",
          "how_much"
        ],
        "why": "When: the date something first happens. How much: the value of a quantity in one or more periods."
      },
      {
        "default": null,
        "example": null,
        "required": true,
        "set": false,
        "setting": "title",
        "values": null,
        "why": "A noun phrase without a question mark or years, e.g. \"Date China takes major military action against Taiwan\"."
      }
    ],
    "valid": false
  }
}
```

### POST /api/v1/market-specs

Create a private market from a spec. Auth: write scope.

Request body:

```json
{
  "spec": {
    "anchors": [
      {
        "by": "2028-12",
        "p": 0.15
      },
      {
        "by": "2032-12",
        "p": 0.55
      }
    ],
    "basis": {
      "judgement": "Sample market for directory reviewers.",
      "reference_classes": [
        {
          "description": "Years since Apollo 17 with a crewed lunar landing",
          "evidence": "None in the 53 years 1973-2025: Laplace's rule gives about 2% a year, raised for the crewed landing programmes now under way.",
          "k": 0,
          "n": 53,
          "period": "1973-2025",
          "sources": [
            {
              "title": "NASA history of the Apollo programme"
            }
          ]
        }
      ]
    },
    "horizon": {
      "end": "2032-12"
    },
    "question": "when",
    "resolution": {
      "details": "A spacecraft carrying at least one person lands on the Moon's surface, as announced by whoever flew it.",
      "excludes": [
        "Uncrewed landings",
        "Crewed flybys and orbits without a landing"
      ],
      "source": "The space agency or company that flew the mission"
    },
    "title": "Date a crewed spacecraft next lands on the Moon"
  }
}
```

Response 201:

```json
{
  "data": {
    "code": "~6590",
    "following": true,
    "local_code": "crewed",
    "note": "Private to you and your API tokens. To let others forecast on it, ask for a public listing (the antistatic_request_public_listing tool, or antistatic market request-public on the command line). Admins review requests and you are notified by email and on the site when they decide.",
    "reference": "~6590",
    "subscribed_to_comments": true,
    "title": "Date a crewed spacecraft next lands on the Moon",
    "url": "http://localhost:4002/m/~6590",
    "visibility": "private"
  }
}
```

## Your markets

### GET /api/v1/markets/{code}/publication-request

The public-listing request for a market you own. Auth: read scope.

Response 200:

```json
{
  "data": {
    "market": "t12418",
    "request": null,
    "visibility": "private"
  }
}
```

### PATCH /api/v1/markets/{code}

Edit a market you own: title, unit, item labels and colours, resolution and background. Auth: write scope.

Any of `title`, `unit`, `items` (`[{key, label, color}]`), and for private markets `resolution_criteria` and `background` (HTML).

Request body:

```json
{
  "title": "Renamed",
  "unit": "cases"
}
```

Response 200:

```json
{
  "data": {
    "background_info_html": null,
    "budget": 100.0,
    "code": "mcp-test",
    "cumulative": true,
    "end_date": "2027-11-14T19:22:06Z",
    "id": 5647,
    "inserted_at": "2026-10-10T19:22:06Z",
    "local_code": "mcp-test",
    "reference": "mcp-test",
    "resolution_criteria": "Criteria",
    "status": "open",
    "title": "Renamed",
    "type": "count",
    "visibility": "public",
    "x_unit": "cases"
  }
}
```

### POST /api/v1/markets/{code}/extend

Widen a how-much market's thresholds, add later periods, or move a when-market's horizon later. Auth: write scope.

For a market you own. How-much: widen its thresholds when values near its top or bottom bar; add later periods when its last period is ending. `thresholds` `{to, from}`: a new top and/or bottom, the ladder continued at its scale and spacing; `to`: the last new period ("YYYY-MM" or a date), at the market's cadence; `anchors`: as a market spec's (`[{period, quantiles: {10, 50, 90}}]` or `[{period, points}]`), naming open or new periods. When: `to` is the new last date, at the market's cadence, and `anchors` (`[{by, p}]`) name dates after the curve's last bar, at or above its chance. A market with items takes anchors by item key (`{"anthropic": [...]}`); `items` (keys) picks the items to extend, all of them by default (a when-market's open ones); each item's curve extends on its own ladder and periods. New bars are priced from the anchors' fitted line, joined onto each curve's current end bar. Existing bars, positions, forecasts and resolutions stay. Each period keeps its total liquidity (a widened period's bars share it); new periods bring their own at the market's rate, and the budget grows by that. A settled period's new bars settle from its recorded value (or the bars it settled); past its old end bar with no recorded value they stay open and are listed in `needs_value` (period ends, prefixed by the item key on a market with items) to resolve. `preview: true` checks it and returns the bars without changing anything. Errors: 422 `invalid_params`, or `invalid_spec` with `issues`.

Request body:

```json
{
  "anchors": [
    {
      "period": "2026-11",
      "quantiles": {
        "10": 15,
        "50": 35,
        "90": 60
      }
    }
  ],
  "preview": true,
  "thresholds": {
    "to": 70
  }
}
```

Response 200:

```json
{
  "data": {
    "added": 2,
    "bars": [
      {
        "end_date": "2026-11-30T23:59:59Z",
        "item": null,
        "probability": 0.042,
        "resolved_yes": null,
        "threshold": 60.0
      },
      {
        "end_date": "2026-11-30T23:59:59Z",
        "item": null,
        "probability": 0.009,
        "resolved_yes": null,
        "threshold": 70.0
      }
    ],
    "needs_value": [],
    "preview": true,
    "settled": 0
  }
}
```

### POST /api/v1/markets/{code}/publication-request

Ask for a market you own to be listed publicly. Auth: write scope.

Request body:

```json
{
  "message": "Useful for everyone"
}
```

Response 201:

```json
{
  "data": {
    "market": "t4615",
    "request": {
      "admin_note": null,
      "decided_at": null,
      "id": 71,
      "message": "Useful for everyone",
      "requested_at": "2026-10-10T19:17:22Z",
      "status": "pending",
      "updated_at": "2026-10-10T19:17:22Z"
    },
    "visibility": "private"
  }
}
```

### DELETE /api/v1/markets/{code}/publication-request

Withdraw a public-listing request. Auth: write scope.

Response 200:

```json
{
  "data": {
    "market": "t2342",
    "request": {
      "admin_note": null,
      "decided_at": null,
      "id": 70,
      "message": "first",
      "requested_at": "2026-10-10T19:17:16Z",
      "status": "withdrawn",
      "updated_at": "2026-10-10T19:17:21Z"
    },
    "visibility": "private"
  }
}
```

### PUT /api/v1/markets/{code}/status

Resolve, close, reopen or open a market or some of its bars (owner of a private market, or admin). Auth: write scope.

`status` is `resolved`, `closed` or `open` (only admins and owners of private markets close or reopen). Resolve a when-market with `event_occurred` and `resolved_at`, a how-much period with `projection_group` and `resolved_value`, or named bars with `submarket_ids`/`submarket_outcomes`. Trades made once the outcome was known (`known_at`) are voided.

Request body:

```json
{
  "status": "open"
}
```

Response 200:

```json
{
  "data": {
    "background_info_html": null,
    "budget": 120.5,
    "code": "~5145",
    "cumulative": true,
    "end_date": null,
    "id": 5145,
    "inserted_at": "2026-10-10T19:18:02Z",
    "local_code": "t17252",
    "reference": "~5145",
    "resolution_criteria": "some resolution_criteria",
    "status": "open",
    "title": "some title",
    "type": "count",
    "visibility": "private",
    "x_unit": "some x_unit"
  }
}
```

## Sharing

### GET /api/v1/markets/{code}/share-link

The live share link for a private market you own (Pro). Auth: read scope.

Response 200:

```json
{
  "data": {
    "created_at": "2026-10-10T19:18:06Z",
    "expires_at": "2026-10-17T19:18:06Z",
    "revoked_at": null,
    "state": "active",
    "url": "http://localhost:4002/share/qXKUlRVlSVL_Fi379UhTKauk"
  }
}
```

### GET /api/v1/markets/{code}/access-requests

Pending requests to join a private market you own. Auth: read scope.

Response 200:

```json
{
  "data": [
    {
      "decided_at": null,
      "id": 102,
      "requested_at": "2026-10-10T19:18:05Z",
      "status": "pending",
      "user": {
        "id": 8070,
        "username": "yzfxbew"
      }
    }
  ]
}
```

### GET /api/v1/markets/{code}/members

Members of a private market you own. Auth: read scope.

Response 200:

```json
{
  "data": {
    "count": 0,
    "limit": 25,
    "members": []
  }
}
```

### POST /api/v1/markets/{code}/share-link

Create (or replace) a private market's share link (Pro). Auth: write scope.

Request body:

```json
{
  "ttl_days": 7
}
```

Response 201:

```json
{
  "data": {
    "created_at": "2026-10-10T19:18:06Z",
    "expires_at": "2026-10-17T19:18:06Z",
    "revoked_at": null,
    "state": "active",
    "url": "http://localhost:4002/share/qXKUlRVlSVL_Fi379UhTKauk"
  }
}
```

### DELETE /api/v1/markets/{code}/share-link

Revoke a private market's share link. Auth: write scope.

Response 200:

```json
{
  "data": {
    "created_at": "2026-10-10T19:18:06Z",
    "expires_at": "2026-10-17T19:18:06Z",
    "revoked_at": "2026-10-10T19:18:06Z",
    "state": "revoked",
    "url": "http://localhost:4002/share/qXKUlRVlSVL_Fi379UhTKauk"
  }
}
```

### POST /api/v1/markets/{code}/access-requests/{id}/approve

Approve a request to join a private market. Auth: write scope.

Response 200:

```json
{
  "data": {
    "decided_at": "2026-10-10T19:18:05Z",
    "id": 102,
    "requested_at": "2026-10-10T19:18:05Z",
    "status": "approved",
    "user": {
      "id": 8070,
      "username": "yzfxbew"
    }
  }
}
```

### POST /api/v1/markets/{code}/access-requests/{id}/decline

Decline a request to join a private market. Auth: write scope.

Response 200:

```json
{
  "data": {
    "decided_at": "2026-10-10T19:18:08Z",
    "id": 103,
    "requested_at": "2026-10-10T19:18:08Z",
    "status": "declined",
    "user": {
      "id": 8124,
      "username": "vanwtas"
    }
  }
}
```

### DELETE /api/v1/markets/{code}/members/{user_id}

Remove a member from a private market. Auth: write scope.

Response 200:

```json
{
  "data": {
    "removed": true,
    "user_id": 8146
  }
}
```

## Account

### GET /api/v1/me/bot

Your bot account, if you have one. Auth: read scope.

Response 200:

```json
{
  "bot": null
}
```

### GET /api/v1/me/plan

Your plan tier (free, pro or supporter) and its limits. Auth: read scope.

Response 200:

```json
{
  "data": {
    "billing_configured": false,
    "can_share_private_markets": true,
    "current_period_end": null,
    "lapsed": false,
    "manage_url": "http://localhost:4002/users/settings",
    "past_due": false,
    "plan": "pro",
    "share_member_limit": 25,
    "status": null
  }
}
```

### GET /api/v1/me/settings

Your notification and forecasting settings. Auth: read scope.

Response 200:

```json
{
  "settings": {
    "auto_double_down": false,
    "bot": null,
    "email": {
      "bot_comments": false,
      "double_down_opportunities": true,
      "forecast_resolution": true,
      "market_resolution": true,
      "mentions": true,
      "new_comments": true,
      "replies": true
    },
    "follow_own_markets": true,
    "sensitive_settings_url": "http://localhost:4002/users/settings",
    "share_sandbox_comments": false,
    "show_other_players_comments": false,
    "subscribe_to_own_market_comments": true
  }
}
```

### GET /api/v1/markets/{code}/follow

Whether you follow a market and are subscribed to its comments. Auth: read scope.

Response 200:

```json
{
  "following": true,
  "subscribed": false
}
```

### POST /api/v1/me/bot

Create your bot account. Auth: write scope.

Response 201:

```json
{
  "bot": {
    "paused": false,
    "profile_url": "http://localhost:4002/u/finn-bot",
    "username": "finn-bot"
  }
}
```

### POST /api/v1/feedback

Send a problem or suggestion to the Antistatic team. Auth: write scope.

Request body:

```json
{
  "client": "MCP (default)",
  "kind": "suggestion",
  "message": "Previews could show bar counts"
}
```

Response 201:

```json
{
  "kind": "suggestion",
  "ok": true
}
```

### PATCH /api/v1/me/settings

Change your settings (a partial object of the GET shape). Auth: write scope.

Unknown keys are refused with 422. Email, password, API tokens, connections and billing change only at `sensitive_settings_url`.

Request body:

```json
{
  "email": {
    "bot_comments": true
  }
}
```

Response 200:

```json
{
  "settings": {
    "auto_double_down": false,
    "bot": null,
    "email": {
      "bot_comments": true,
      "double_down_opportunities": true,
      "forecast_resolution": true,
      "market_resolution": true,
      "mentions": true,
      "new_comments": true,
      "replies": true
    },
    "follow_own_markets": true,
    "sensitive_settings_url": "http://localhost:4002/users/settings",
    "share_sandbox_comments": false,
    "show_other_players_comments": false,
    "subscribe_to_own_market_comments": true
  }
}
```

### PUT /api/v1/markets/{code}/follow

Follow a market. Auth: write scope.

Response 200:

```json
{
  "following": true
}
```

### DELETE /api/v1/markets/{code}/follow

Unfollow a market. Auth: write scope.

Response 200:

```json
{
  "following": false
}
```

### PUT /api/v1/markets/{code}/comment-subscription

Get notified of a market's new comments. Auth: write scope.

Response 200:

```json
{
  "subscribed": true
}
```

### DELETE /api/v1/markets/{code}/comment-subscription

Stop notifications of a market's new comments. Auth: write scope.

Response 200:

```json
{
  "subscribed": false
}
```

## Sandboxes

### GET /api/v1/sandbox

Sandboxes you can start (curricula and wargame templates), and your runs. Auth: read scope.

Response 200:

```json
{
  "data": {
    "curricula": [],
    "future_sandboxes": {
      "included": true
    },
    "runs": [],
    "templates": [
      {
        "key": "wargame-your-life",
        "kind": "future",
        "premise": "It is October 2026. …",
        "title": "Wargame your life"
      }
    ]
  }
}
```

### GET /api/v1/sandbox/runs/{id}

Where one of your sandbox runs stands: a past run's clock, checkpoint and questions, or a wargame's setup and history. Auth: read scope.

A past sandbox's position (`kind: past`), or a wargame's (`kind: future`): its clock, setup, the results of its last 50 turns and the questions written in it. Nothing past the clock. In a wargame you play but don't host, or with `character` (one of yours), the run as that character sees it: the shared story with its own results and info answers, `role: guest` and its `character`.

Query: `character`, `include_audit`.

Response 200:

```json
{
  "data": {
    "blockers": [
      {
        "key": "paper-a",
        "reason": "forecast",
        "title": "Will the paper's primary endpoint be positive?"
      }
    ],
    "checkpoint": {
      "key": "opening",
      "occurred_at": "2020-01-01T00:00:00Z",
      "sequence": 0,
      "text": "Only prior evidence is visible.",
      "title": "Opening evidence"
    },
    "clock": "2020-01-01T00:00:00Z",
    "complete": false,
    "curriculum": {
      "slug": "retro-42115",
      "title": "Biology sandbox 42115"
    },
    "kind": "past",
    "pending_resolutions": [],
    "previous_checkpoint_at": null,
    "questions": [
      {
        "contaminated": false,
        "key": "paper-a",
        "market_code": "rt42115",
        "moderation": null,
        "open": true,
        "private": false,
        "resolved": false,
        "submitted": null,
        "title": "Will the paper's primary endpoint be positive?"
      }
    ],
    "run_id": 609,
    "status": "active",
    "url": "http://localhost:4002/sandbox/retro-42115"
  }
}
```

### GET /api/v1/sandbox/runs/{id}/audit

How a wargame's completed turns were decided: the server's seeded rolls. Auth: read scope.

For the run's owner, per completed turn (oldest first; `turn` picks one by number): `seed_hex` (random per turn), `planner` (server or client), `model` (the model the turn was planned with, fixed when it started; null for client turns), `cost_usd` (its exact model cost at list prices; null for client turns), the optional `duties` rolled (duty, rate, p, u, fired), every visible `draws` entry (kind draw, sample or curveball; label, p or distribution, basis, u, result, action), `sealed_draws` (how many draws the planner sealed: counted, not shown), `question_rolls` (each run-question item rolled: market, title, item, item_label, period, `source` (house; or, in a run with sample_from user, user, fallback when the user had no forecast, or mixed with user_bars of bars), line_p (the p from the line rolled), house_p (when the source is house), the planner's adjustment {factor, basis}, p, u, result and date; for a how_much period: source, line [{threshold, p, source}] (and house, the same, when the source is house), median, u and result {at_least, below}), the run's `mode` (mortal or god), `goals` after the turn (null for a run without goals; else mode, scored, total {realised, unrealised, total} and goals [{market, liquidity, turn, priced_at, realised, unrealised, total, bars [{bar, p0, p, forecast, resolved_yes, realised, unrealised}]}]) and `branches` (labels, weights, chosen index, u). A result is yes when u < p. Nothing about turns not yet completed, and no planner evidence, notes or unseen observations. Errors: 404 (not your wargame), 422 (turn is not a number).

Query: `turn`.

Response 200:

```json
{
  "data": {
    "run_id": 30,
    "turns": [
      {
        "branches": {
          "chosen": 0,
          "labels": [
            "Reply, scheme open",
            "No reply"
          ],
          "u": 0.31,
          "weights": [
            0.6,
            0.4
          ]
        },
        "draws": [
          {
            "basis": "…",
            "kind": "draw",
            "label": "The ward manager replies this week",
            "n": 1,
            "p": 0.5,
            "result": "yes",
            "u": 0.4
          }
        ],
        "duties": [],
        "planner": "client",
        "question_rolls": [],
        "sealed_draws": 0,
        "seed_hex": "9f2c…",
        "turn": 1
      }
    ]
  }
}
```

### GET /api/v1/sandbox/runs/{id}/sources

A run's sources up to its clock: the briefing, reached disclosures and new feed items. Auth: read scope.

`since=YYYY-MM-DD` (default: the previous checkpoint's date; `since=start` for everything reached) bounds the disclosures and the feed. `limit` caps disclosures (max 200). The feed needs Pro or curriculum membership; otherwise its `status` is `not_included`.

Query: `limit`, `since`.

Example query: `?since=start`

Response 200:

```json
{
  "data": {
    "briefing": {
      "checkpoint": {
        "key": "opening",
        "text": "Only prior evidence is visible.",
        "title": "Opening evidence"
      },
      "text": "Historical biology questions.",
      "title": "Biology sandbox 42115"
    },
    "clock": "2020-01-01T00:00:00Z",
    "disclosures": [
      {
        "at": "2020-01-01T00:00:00Z",
        "checkpoint_key": "opening",
        "key": "study-design",
        "kind": "evidence",
        "question_code": "rt42115",
        "source_url": null,
        "text": "Forty subjects and a preregistered endpoint.",
        "title": "Study design"
      }
    ],
    "feed": {
      "items": [],
      "status": "unavailable"
    },
    "since": null
  }
}
```

### GET /api/v1/sandbox/runs/{id}/library

Search a run's library, capped at the run's clock. Auth: read scope.

`q` is required. `limit` (max 15), `offset`, `after=YYYY-MM-DD`, `sort=relevance|newest`. The cutoff comes from the run's clock: a source dated D is available from D+1. Needs Pro or curriculum membership.

Query: `after`, `limit`, `offset`, `q`, `sort`.

Example query: `?q=widget`

Response 200:

```json
{
  "data": {
    "cutoff": "2020-01-01",
    "more": false,
    "next_offset": null,
    "results": [
      {
        "date": "2019-12-31",
        "doc_id": "a",
        "title": "Reached"
      }
    ]
  }
}
```

### GET /api/v1/sandbox/runs/{id}/turn_status

Where a wargame's next turn stands for you: players, the turn's length, what you do next; can long-poll. Auth: read scope.

For the run's host or a guest who joined it. `wait_seconds` (0-60) long-polls: the answer comes as soon as the status differs from `since` (a `version` from an earlier answer; absent, the status now), or after `wait_seconds` with the status as it is. 404 for a run you don't play.

Query: `character`, `since`, `wait_seconds`.

Example query: `?since=1x9k1&wait_seconds=30`

Response 200:

```json
{
  "data": {
    "busy": null,
    "can_run": false,
    "clock": "2026-11-14",
    "expected": 1,
    "last_length": {
      "from": "2026-10-14",
      "span": "1 month",
      "to": "2026-11-14"
    },
    "length": {
      "label": "to 14 Dec 2026 (1 month)",
      "span": "1 month",
      "state": "locked",
      "until": "2026-12-14"
    },
    "multiplayer": true,
    "next": {
      "action": "wait",
      "text": "Submitted. Waiting for the host to run the turn."
    },
    "open_questions": [],
    "players": [
      {
        "key": "c1",
        "name": "Ana",
        "orders": null,
        "reopened": false,
        "role": "host",
        "stale": false,
        "status": "host",
        "submitted_for": null,
        "submitted_for_label": null,
        "you": false
      },
      {
        "key": "c2",
        "name": "Rosa",
        "orders": 2,
        "reopened": false,
        "role": "guest",
        "stale": false,
        "status": "submitted",
        "submitted_for": "2026-12-14",
        "submitted_for_label": "to 14 Dec 2026 (1 month)",
        "you": true
      }
    ],
    "role": "guest",
    "run_id": 712,
    "submitted": 1,
    "turn": 4,
    "version": "1x9k2",
    "waiting_on": []
  }
}
```

### GET /api/v1/sandbox/runs/{id}/orders

A wargame character's draft orders for the next turn. Auth: read scope.

The host's own draft without `character`; else that character's (required when you have several in the game). 404 for a run you don't play.

Query: `character`.

Example query: `?character=c3`

Response 200:

```json
{
  "data": {
    "character": "c3",
    "entries": [
      {
        "id": "oAbC123",
        "source": "mcp",
        "text": "Announce a pause on frontier training.",
        "type": "action"
      }
    ],
    "role": "guest",
    "run_id": 42,
    "submitted_at": "2026-10-13T12:00:00Z"
  }
}
```

### POST /api/v1/sandbox/runs

Start a sandbox: a future one from a template or the player's own scenario, or a past one from a curriculum. Auth: write scope.

`template` starts a wargame on `starts_on` (default today; any past or future date; where the plan allows; at most 5 active), with an optional `model` (`opus`, the default, `sonnet` or `haiku`: the model that simulates the run). `title` and `premise` without `template` start a wargame of the player's own scenario (the premise is free text: one line or a long brief; world rules, pacing and curveball guidance are the shared defaults); with `template` they replace its own. `bio` sets the player's bio from the outset. A run started with a premise or a bio is set up: its first turn can be taken. The response is the run with a `setup` object (the scenario's title and premise, the bio prompt and choices, how turn lengths are chosen, the curveball guidance) whose answers go to `PATCH /sandbox/runs/{id}/setup`. `curriculum` (a slug) starts or resumes a past sandbox and returns its position (`kind: past`). `planner`: `client` (the default here: the caller's own agent plans through planner jobs, with no Antistatic model calls) or `server` (Antistatic's model, on the monthly AI budget in `GET /sandbox`); runs started on the website are server-planned. Errors: 429 `sandbox_quota`, 422 for a missing title or premise. `characters` makes a cast for the caller as the run starts (they host and umpire, with no character of their own).

Request body:

```json
{
  "template": "wargame-your-life"
}
```

Response 201:

```json
{
  "data": {
    "clock": "2026-10-10T09:00:00Z",
    "forked_from_run_id": null,
    "history": [],
    "kind": "future",
    "questions": [],
    "run_id": 712,
    "settings": {
      "bio": null,
      "complete": false,
      "modifications": null,
      "pace": "month"
    },
    "setup": {
      "bio_choices": [
        {
          "key": "draft_from_context",
          "label": "Let the assistant draft it from what it knows about you, then edit"
        },
        {
          "key": "free_text",
          "label": "Write it yourself"
        },
        {
          "key": "skip",
          "label": "Skip: play an unnamed person"
        }
      ],
      "bio_prompt": "Who is the player? …",
      "curveballs": "Curveballs are life events outside the story, drawn at their base rates.",
      "default_pace": "auto",
      "fields": {
        "bio": "text, at most 6000 characters"
      },
      "pacing": "Each turn's length is chosen when the turn is taken.",
      "privacy": "The bio, modifications and actions go to Antistatic's planner …",
      "template": {
        "key": "wargame-your-life",
        "premise": "It is October 2026. …",
        "title": "Wargame your life"
      }
    },
    "status": "active",
    "template": {
      "key": "wargame-your-life",
      "title": "Wargame your life"
    },
    "turns": 0,
    "url": "https://antistatic.exchange/sandbox/future-1a2b3c4d5e6f"
  }
}
```

### PATCH /api/v1/sandbox/runs/{id}/setup

Set a wargame's premise, bio and other settings. Auth: write scope.

Any of `title` (at most 120 characters), `premise`, `bio` (6,000), `modifications` (2,000), `pace` (`auto`, the default: each turn's length is chosen when it is taken; or a fixed length: `days`, `week`, `month`, `quarter`) and `model` (`opus`, `sonnet` or `haiku`: the model that simulates the run's next turns, info answers and briefings); omitted fields stay, empty text clears (a cleared premise or title falls back to the template's). Each change of the premise or the bio is logged with the run's date and turn (`settings.setup_log`) and sent to the planner. Any setup completes it, which turns need; a complete setup before the first turn gets an opening briefing (again when the premise, bio or modifications change, up to a daily limit). Returns the run.

Request body:

```json
{
  "bio": "A nurse in Leeds.",
  "pace": "month"
}
```

Response 200:

```json
{
  "data": {
    "clock": "2026-10-10T09:00:00Z",
    "forked_from_run_id": null,
    "history": [],
    "kind": "future",
    "questions": [],
    "run_id": 712,
    "settings": {
      "bio": "A nurse in Leeds.",
      "complete": true,
      "modifications": null,
      "pace": "month"
    },
    "status": "active",
    "template": {
      "key": "wargame-your-life",
      "title": "Wargame your life"
    },
    "turns": 0,
    "url": "https://antistatic.exchange/sandbox/future-1a2b3c4d5e6f"
  }
}
```

### POST /api/v1/sandbox/runs/{id}/turns

Take a wargame's turn. Auth: write scope.

The player's typed `entries` (each `{type, text}`, type `action`, `decree`, `feed` or `info`; at least one action or decree; at most 20 entries and 200,000 characters per character, `character` naming one, untagged the host's) and optionally `until` (an ISO date after the clock, at most a year ahead; a date-time counts as its date; default the clock plus the pace; `"auto"`: the planner chooses the date, keeping to the run's cadence (recent turn lengths, else the pace) or a sensible step, and the result's `until` gives it with a one-line reason, also stated under the script's header). An action is an attempt the planner forecasts; a decree happens exactly as stated; a feed is a binding correction recorded before planning (with `strike`, an observation id, it also strikes that observation); an info entry is answered from what the player already knew, as of the turn's start (`answers_as_of`), so it can't shape the turn's actions (ask those first with POST /sandbox/runs/{id}/info). `actions` (text) is deprecated: one action. The server plans the period, draws one outcome in code, moves the clock to midnight UTC of that date and narrates the drawn outcome; the response holds `script` (the narration), the observations the clock has reached, an outcome per action and decree, `answers` to info entries, the run's bars that resolved, and any run questions the planner created (`questions_created`). Idempotent by `turn_id` (the client's key, per run): a completed turn sent again with the same entries and `until` returns its stored result with `replayed: true`; the same `turn_id` with other entries or another `until` is 409 `turn_id_conflict`. A daily turn quota. Errors: 409 `setup_incomplete`, 409 `turn_in_progress`, 409 `turn_id_conflict`, 429 `turn_quota` (turns per user per day) or `info_quota` (with info entries), 503 `at_capacity` (the service's daily cap), 403 `model_requires_pro` (a free account's server turns run on Haiku only), 402 `ai_budget_exhausted` (the month's AI budget is used up; client mode still works; admins exempt), all checked on every call, retries included, 502 `planner_invalid` / `planner_refused` and 503 `planner_unavailable` (the clock has not moved; retry with the same `turn_id`). When the planner needs the player's answers before the period can be simulated, the response is `status: "questions"` with `questions_for_player` and nothing else happens (no turn counted, the clock stays): ask the player and retry the same `turn_id` with the answers as feed entries (`answers`: id).

Request body:

```json
{
  "entries": [
    {
      "text": "Ask my manager for a pay review.",
      "type": "action"
    },
    {
      "text": "When is my contract up for renewal?",
      "type": "info"
    }
  ],
  "turn_id": "7f9c2b1e-turn-1"
}
```

Response 200:

```json
{
  "data": {
    "action_outcomes": [
      {
        "action": "Ask my manager for a pay review.",
        "note": "Your manager agreed to review your pay in January.",
        "status": "done"
      }
    ],
    "answers": "**When is my contract up for renewal?**\nYour contract renews on 31 March; the HR portal shows it.",
    "clock": "2026-11-10T09:00:00Z",
    "entries": [
      {
        "text": "Ask my manager for a pay review.",
        "type": "action"
      },
      {
        "text": "When is my contract up for renewal?",
        "type": "info"
      }
    ],
    "observations": [
      {
        "at": "2026-10-24T09:00:00Z",
        "id": "o3f9a1c2b7d4e",
        "text": "Your manager agrees to a pay review in January."
      }
    ],
    "replayed": false,
    "resolved": [],
    "run_id": 712,
    "script": "**10 October to 10 November 2026 · T+31 days**\n\nOn 24 October your manager agrees to a pay review in January.\n\n**Results**\n- Ask my manager for a pay review: done; the review is booked for January.\n\n**Elsewhere**\nNothing in the wider world touched your month.\n\n**Markets**\nNothing resolved.\n\nWhat do you do?",
    "turn": {
      "from": "2026-10-10T09:00:00Z",
      "index": 1,
      "to": "2026-11-10T09:00:00Z"
    }
  }
}
```

### POST /api/v1/sandbox/runs/{id}/info

Info and feed entries in a wargame, with no time passing. Auth: write scope.

`entries`: `{type, text}` with type `info` or `feed` (at most 20, each up to 1,000 characters). Feeds are recorded first as binding corrections (with `strike`, an observation id, also striking it); info entries are questions about what the player would already know, answered consistently with the run's hidden state and logged. The clock does not move. Pro (or admin), checked on every call. Errors: 403 `pro_required`, 429 `info_quota` (info answers per user per day), 503 `at_capacity`, 502 `planner_invalid` / `planner_refused`, 503 `planner_unavailable` (the feeds stay recorded).

Request body:

```json
{
  "entries": [
    {
      "text": "How much is in my savings account?",
      "type": "info"
    }
  ]
}
```

Response 200:

```json
{
  "data": {
    "answer": "**How much is in my savings account?**\nAbout £18,000, roughly nine months of spending.",
    "feeds_recorded": 0,
    "run_id": 712
  }
}
```

### PUT /api/v1/sandbox/runs/{id}/orders

Replace a wargame character's orders and submit them for the host's turn. Auth: write scope.

`entries` (`{type, text}`: `action` or `decree`, at most 20 and 200,000 characters) replace the character's draft. A player's character's orders are submitted for the host's next turn unless `submit` is false; the host's own go with the turn they take. `character` as for sandbox.orders.

Request body:

```json
{
  "character": "c3",
  "entries": [
    {
      "text": "Announce a pause on frontier training.",
      "type": "action"
    }
  ]
}
```

Response 200:

```json
{
  "data": {
    "character": "c3",
    "entries": [
      {
        "id": "oAbC123",
        "source": "mcp",
        "text": "Announce a pause on frontier training.",
        "type": "action"
      }
    ],
    "role": "guest",
    "run_id": 42,
    "submitted_at": "2026-10-13T12:00:00Z"
  }
}
```

### POST /api/v1/sandbox/runs/{id}/corrections

Record a fact about the player's real situation in a wargame. Auth: write scope.

Adds `fact` (text, at most 1,000 characters; at most 50 per run) to the run's corrections. The planner takes the run's corrections as established facts of its world from the next turn on. Returns the run.

Request body:

```json
{
  "fact": "The app has not launched; it has no paying users."
}
```

Response 201:

```json
{
  "data": {
    "clock": "2026-11-10T09:00:00Z",
    "corrections": [
      {
        "at": "2026-11-10T18:02:11Z",
        "text": "The app has not launched; it has no paying users."
      }
    ],
    "forked_from_run_id": null,
    "history": [
      {
        "clock": "2026-11-10T09:00:00Z",
        "observations": [
          {
            "at": "2026-10-24T09:00:00Z",
            "id": "o3f9a1c2b7d4e",
            "text": "Your manager agrees to a pay review in January."
          }
        ],
        "resolved": [],
        "run_id": 712,
        "turn": {
          "from": "2026-10-10T09:00:00Z",
          "index": 1,
          "to": "2026-11-10T09:00:00Z"
        }
      }
    ],
    "kind": "future",
    "questions": [],
    "run_id": 712,
    "settings": {
      "bio": "A nurse in Leeds.",
      "complete": true,
      "modifications": null,
      "pace": "month"
    },
    "status": "active",
    "struck_observations": [],
    "template": {
      "key": "wargame-your-life",
      "title": "Wargame your life"
    },
    "turns": 1,
    "url": "https://antistatic.exchange/sandbox/future-1a2b3c4d5e6f"
  }
}
```

### PUT /api/v1/sandbox/runs/{id}/goals/{code}

Mark a wargame's run question as a goal, with its liquidity. Auth: write scope.

Marks the run question `code` (one of the run's own questions) as a goal with `liquidity` (a whole number of points from 100 to 1,000: the market's budget, as on the live site), changes its liquidity, or with `remove: true` removes it. At most 10 goals; a goal's liquidity is fixed once a turn has passed since it was set. In a mortal run the player is scored on goals with the regular market points of their run positions: the budget is spread over the bars with the live engine's constant daily decay on the run's clock, and points come as bars resolve. A goal is priced when set (each open bar's house p and the player's position); only points made after count. Decrees and feeds can't settle or move a goal of a mortal run. Returns the run, with `score`. Errors: 404 (not your wargame), 422 (not a run question, liquidity out of range, a goal fixed by a turn, too many goals).

Request body:

```json
{
  "liquidity": 600
}
```

Response 200: too long to show here; see the example in the contract.

### POST /api/v1/sandbox/runs/{id}/observations/{observation_id}/strike

Strike an observation from a wargame's history. Auth: write scope.

Strikes an observation the run has reached (its `id` from the history or a turn's result), with `reason` (text, at most 500 characters). It stays stored for audit but is left out of the history, turn results and the planner's input. Returns the run.

Request body:

```json
{
  "reason": "No pay review is possible before April."
}
```

Response 200:

```json
{
  "data": {
    "clock": "2026-11-10T09:00:00Z",
    "corrections": [
      {
        "at": "2026-11-10T18:02:11Z",
        "text": "The app has not launched; it has no paying users."
      }
    ],
    "forked_from_run_id": null,
    "history": [
      {
        "clock": "2026-11-10T09:00:00Z",
        "observations": [],
        "resolved": [],
        "run_id": 712,
        "turn": {
          "from": "2026-10-10T09:00:00Z",
          "index": 1,
          "to": "2026-11-10T09:00:00Z"
        }
      }
    ],
    "kind": "future",
    "questions": [],
    "run_id": 712,
    "settings": {
      "bio": "A nurse in Leeds.",
      "complete": true,
      "modifications": null,
      "pace": "month"
    },
    "status": "active",
    "struck_observations": [
      {
        "at": "2026-11-10T18:03:40Z",
        "id": "o3f9a1c2b7d4e",
        "reason": "No pay review is possible before April."
      }
    ],
    "template": {
      "key": "wargame-your-life",
      "title": "Wargame your life"
    },
    "turns": 1,
    "url": "https://antistatic.exchange/sandbox/future-1a2b3c4d5e6f"
  }
}
```

### POST /api/v1/sandbox/runs/{id}/forks

Fork a sandbox run. Auth: write scope.

A wargame forks after `turn` (default its last; 0 is the start) into a new run with its history, clock and planner state up to then (`model` sets its planner model; default the original's), and a copy of every question as it stood after that turn: specs, links, goals, forecasts, positions, house line, liquidity and the resolutions reached by then, under the fork's own codes (p<fork id>-<n>, same n). Later changes in either run stay in that run. A past run forks at a reached `checkpoint` (key; default the current one) into a new run marked `practice`. The original is unchanged.

Request body:

```json
{
  "turn": 1
}
```

Response 201:

```json
{
  "data": {
    "clock": "2026-11-10T09:00:00Z",
    "forked_from_run_id": 712,
    "history": [
      {
        "clock": "2026-11-10T09:00:00Z",
        "observations": [
          {
            "at": "2026-10-24T09:00:00Z",
            "text": "Your manager agrees to a pay review in January."
          }
        ],
        "resolved": [],
        "run_id": 712,
        "turn": {
          "from": "2026-10-10T09:00:00Z",
          "index": 1,
          "to": "2026-11-10T09:00:00Z"
        }
      }
    ],
    "kind": "future",
    "questions": [],
    "run_id": 713,
    "settings": {
      "bio": "A nurse in Leeds.",
      "complete": true,
      "modifications": null,
      "pace": "month"
    },
    "status": "active",
    "template": {
      "key": "wargame-your-life",
      "title": "Wargame your life"
    },
    "turns": 1,
    "url": "https://antistatic.exchange/sandbox/future-1a2b3c4d5e6f"
  }
}
```

### GET /api/v1/sandbox/runs/{id}/importable-questions

List questions importable from your other wargames. Auth: read scope.

The questions in the caller's other wargames (not abandoned or deleted; archived ones included), newest runs first: `run_id`, `template`, `code`, `title`, `definition_sha256` (a hash of the question's spec without its code; identical definitions share it) and `already_here` (this run has a question with the same hash). 404 unless `id` is the caller's wargame.

Response 200:

```json
{
  "data": {
    "questions": [
      {
        "already_here": false,
        "code": "p31-2",
        "definition_sha256": "3f9a…",
        "run_id": 31,
        "template": "wargame-your-life",
        "title": "When will I change job?"
      }
    ]
  }
}
```

### POST /api/v1/sandbox/runs/{id}/questions/import

Import questions from another of your wargames. Auth: write scope.

Copies questions `codes` from the caller's wargame `from_run_id` into run `id`: each a new question with the same definition, checked at this run's clock, and fresh state (no forecasts, positions, resolutions or goal). All or nothing. 201 with `data.imported` [{from, code}]; 422 `invalid_spec` when a definition doesn't fit this run's clock; 404 when either run isn't the caller's wargame.

Request body:

```json
{
  "codes": [
    "p31-2"
  ],
  "from_run_id": 31
}
```

Response 201:

```json
{
  "data": {
    "imported": [
      {
        "code": "p30-4",
        "from": "p31-2"
      }
    ]
  }
}
```

### POST /api/v1/sandbox/runs/{id}/wrapup

Wrap up a wargame. Auth: write scope.

Makes the run's wrap-up now (also made automatically when a server-planned run's clock reaches `ends_on`). A server-planned run: 201 with `status: done` and the `wrapup`, written by the run's planner model and gated and charged like a turn (`model_requires_pro`, `ai_budget_exhausted`). A client-planned run: 202 with `status: wrapup_job` and a `job_id` for the run's own planner, which reads the facts with GET /api/v1/sandbox/planner_jobs/{job_id} and submits the arc with POST .../script. Made again, the earlier wrap-up is kept (`earlier_wrapups` in the run). 422 before the first turn.

Response 201:

```json
{
  "data": {
    "status": "done",
    "wrapup": {
      "arc": "You started the autumn on the ward…",
      "forecasts": {
        "best": [],
        "brier": 0.04,
        "house_brier": 0.16,
        "resolved_bars": 1,
        "right": 1,
        "worst": []
      },
      "generated_at": "2027-01-10T09:00:00Z",
      "model": "claude-opus-4-5",
      "period": {
        "ends_on": "2027-01-01",
        "from": "2026-10-12T00:00:00Z",
        "past_end": true,
        "to": "2027-01-10T00:00:00Z",
        "turns": 3
      },
      "resolutions": [
        {
          "date": "2026-12-04",
          "market": "p30-1",
          "outcome": "happened",
          "title": "When will I change job?"
        }
      ],
      "score": null,
      "turning_points": [],
      "written_by": "server"
    }
  }
}
```

### DELETE /api/v1/sandbox/runs/{id}

Delete one of your sandbox runs. Auth: write scope.

Needs `confirm: true`. The run (a past sandbox's run, or a wargame) disappears for you everywhere: lists, `GET` and every write answer 404 for it, and its page is gone. It is a soft delete: the run and everything in it are kept, so the site's operators can restore it on request. Its forks stay, and show it as a deleted parent. A curriculum stays available: opening it again starts a new run. 404 for a run that isn't yours.

Request body:

```json
{
  "confirm": true
}
```

Response 200:

```json
{
  "data": {
    "deleted": true,
    "run_id": 712
  }
}
```

### PATCH /api/v1/sandbox/runs/{id}

Rename, archive or unarchive one of your sandbox runs. Auth: write scope.

`name` (up to 80 characters; empty or null clears it) renames a wargame wherever it is listed; a past sandbox keeps its curriculum's title (422). `archived: true` takes the run off your sandbox lists (the site's list shows it under Archived) without changing it: it still opens, plays and answers here; `archived: false` puts it back. Send either or both. 404 for a run that isn't yours.

Request body:

```json
{
  "archived": false,
  "name": "Move to Lisbon"
}
```

Response 200:

```json
{
  "data": {
    "archived": false,
    "kind": "future",
    "name": "Move to Lisbon",
    "run_id": 712
  }
}
```

### POST /api/v1/sandbox/runs/{id}/questions/{code}/promote

Copy a wargame's question into the real exchange today. Auth: write scope.

The question's definition is its spec in the run without the simulated `anchors` and `basis`; `spec` replaces any of its settings. Without `create` (or `create: false`) the answer is that spec and its preview as of today (as `POST /market-specs/preview`). Opening probabilities come from evidence available today, never from the sandbox's history: send `anchors` (and `basis`) in `spec`. With `create: true` it is created as `POST /market-specs` does, a normal private market owned by the caller (201); 422 `invalid_spec` while the spec has errors. The run is unchanged. `POST /markets/{code}/publication-request` can then ask for a public listing. 404 for a past sandbox's run or a code that isn't the run's question.

Request body:

```json
{
  "create": true,
  "spec": {
    "anchors": [
      {
        "by": "2027-06",
        "p": 0.3
      },
      {
        "by": "2028-12",
        "p": 0.7
      }
    ],
    "basis": {
      "reference_classes": [
        {
          "description": "Career changes into nursing management",
          "evidence": "About a third of ward nurses who apply are promoted within two years, from the NHS staff survey 2019-2024.",
          "sources": [
            {
              "title": "NHS staff survey",
              "url": "https://www.nhsstaffsurveys.com/"
            }
          ]
        }
      ]
    }
  }
}
```

Response 201:

```json
{
  "data": {
    "code": "~6612",
    "note": "A normal private market, owned by you, with nothing from the sandbox but its definition. To let others forecast on it, ask for a public listing (antistatic_request_public_listing).",
    "promoted_from": {
      "market_code": "p712-1",
      "run_id": 712
    },
    "title": "Date I become a ward manager",
    "url": "https://antistatic.exchange/m/~6612",
    "visibility": "private"
  }
}
```

### GET /api/v1/sandbox/runs

Your sandbox runs. Auth: read scope.

Response 200:

```json
{
  "data": [
    {
      "blockers": [
        {
          "key": "paper-a",
          "reason": "forecast",
          "title": "Will the paper's primary endpoint be positive?"
        }
      ],
      "checkpoint": {
        "key": "opening",
        "occurred_at": "2020-01-01T00:00:00Z",
        "sequence": 0,
        "title": "Opening evidence"
      },
      "complete": false,
      "curriculum": {
        "slug": "retro-35812",
        "title": "Biology sandbox 35812"
      },
      "pending_resolutions": [],
      "questions": [
        {
          "contaminated": false,
          "key": "paper-a",
          "market_code": "rt35812",
          "moderation": null,
          "open": true,
          "private": false,
          "resolved": false,
          "submitted": null,
          "title": "Will the paper's primary endpoint be positive?"
        }
      ],
      "run_id": 618,
      "status": "active"
    }
  ]
}
```

### POST /api/v1/sandbox/runs/{id}/advance

Move a run on to its next checkpoint, or by a week, month or year. Auth: write scope.

Refused with 422 `unanswered_items` while a question closing at the next checkpoint has neither a forecast nor a skip. With `by=week|month|year` it steps through checkpoints up to that much simulated time and stops early at one that discloses evidence, cuts liquidity, resolves or introduces a question; the response adds `step`. There is no endpoint for placing a sandbox forecast: forecasting happens in the web UI.

Response 200:

```json
{
  "data": {
    "blockers": [],
    "checkpoint": {
      "key": "paper-reveal",
      "occurred_at": "2020-01-01T00:00:00Z",
      "sequence": 1,
      "title": "Paper results"
    },
    "complete": false,
    "curriculum": {
      "slug": "retro-35972",
      "title": "Biology sandbox 35972"
    },
    "pending_resolutions": [],
    "questions": [
      {
        "contaminated": false,
        "key": "paper-a",
        "market_code": "rt35972",
        "moderation": null,
        "open": true,
        "private": false,
        "resolved": false,
        "submitted": "skipped",
        "title": "Will the paper's primary endpoint be positive?"
      }
    ],
    "run_id": 621,
    "status": "active"
  }
}
```

### POST /api/v1/sandbox/runs/{id}/resolutions

Resolve a bar in your run. Auth: write scope.

Request body:

```json
{
  "outcome": "no",
  "submarket_id": 46960
}
```

Response 200:

```json
{
  "data": {
    "blockers": [],
    "checkpoint": {
      "key": "a2003",
      "occurred_at": "2003-01-01T00:00:00Z",
      "sequence": 2,
      "title": "a2003"
    },
    "complete": false,
    "curriculum": {
      "slug": "retro-69315",
      "title": "Learner resolution 69283"
    },
    "pending_resolutions": [],
    "questions": [
      {
        "contaminated": false,
        "key": "when",
        "market_code": "lr69283",
        "moderation": null,
        "open": true,
        "private": false,
        "resolved": false,
        "submitted": "skipped",
        "title": "When will it happen?"
      }
    ],
    "resolution": {
      "implied_submarket_ids": [],
      "resolved_yes": false,
      "submarket_id": 46960
    },
    "run_id": 689,
    "status": "active"
  }
}
```

### GET /api/v1/sandbox/runs/{id}/questions

Questions written in a run. Auth: read scope.

Response 200:

```json
{
  "data": [
    {
      "bars": [
        {
          "closes_at": "cp2",
          "end_date": "2001-09-30T23:59:59Z",
          "key": "b1",
          "label": "By end of Sep 2001",
          "resolved": false,
          "resolved_yes": null,
          "submarket_id": 45553,
          "threshold_date": "2001-09-30T23:59:59Z",
          "threshold_decimal": null
        },
        {
          "closes_at": "cp2",
          "end_date": "2001-10-31T23:59:59Z",
          "key": "b2",
          "label": "By end of Oct 2001",
          "resolved": false,
          "resolved_yes": null,
          "submarket_id": 45554,
          "threshold_date": "2001-10-31T23:59:59Z",
          "threshold_decimal": null
        }
      ],
      "code": "p685-1",
      "created_at_checkpoint": "cp1",
      "created_on": "2001-09-01T00:00:00Z",
      "market_type": "date",
      "moderation": null,
      "title": "Date the first widget factory opens"
    }
  ]
}
```

### POST /api/v1/sandbox/runs/{id}/questions

Write a question in a run. Auth: write scope.

Request body:

```json
{
  "spec": {
    "anchors": [
      {
        "by": "2002-06",
        "p": 0.3
      },
      {
        "by": "2003-12",
        "p": 0.6
      }
    ],
    "basis": {
      "reference_classes": [
        {
          "description": "Announced widget factories that opened",
          "evidence": "Invented: 6 of 20 announced factories opened within two years.",
          "k": 6,
          "n": 20,
          "period": "1990-2000",
          "sources": []
        }
      ]
    },
    "horizon": {
      "end": "2003-12"
    },
    "question": "when",
    "resolution": {
      "details": "A factory counts once it ships its first widget.",
      "source": "Reported by the widget trade gazette"
    },
    "title": "Date the first widget factory opens"
  }
}
```

Response 201:

```json
{
  "data": {
    "code": "p679-1",
    "created_at_checkpoint": "cp1",
    "note": "Private to you in this run. It never joins the curriculum; a moderator may flag it, and its outcomes arrive as the run reaches them.",
    "private": true,
    "title": "Date the first widget factory opens",
    "url": "http://localhost:4002/sandbox/retro-354/m/p679-1"
  }
}
```

### POST /api/v1/sandbox/runs/{id}/questions/{code}/items

Add an item to a question in a run. Auth: write scope.

Adds an item (a new event or party, as its own curve) to one of the run's own when-questions with items: `key` (new; lowercase letters, digits, - or _), `label`, `anchors` ([{by, p}]) and `basis` (reference classes with sources or k/n counts, checked as for a new question). The question's whole spec, with the item, is checked as a new question would be; only the new item's bars are added, dated from the run's clock (201). An existing item's `key` with only a `label` renames that item instead (200): its key, bars, anchors and basis stay. 422 `invalid_spec` lists the issues. `also_runs` (ids of the caller's other wargames) makes the same edit on each one's identical question (same `definition_sha256`), never otherwise; `data.also` gives each run's `status`: `edited`, `no_identical_question` or `failed`. A run that isn't the caller's wargame is refused (422).

Request body:

```json
{
  "anchors": [
    {
      "by": "2027-01",
      "p": 0.05
    },
    {
      "by": "2027-06",
      "p": 0.12
    }
  ],
  "basis": {
    "reference_classes": [
      {
        "description": "Final-round candidates hired",
        "evidence": "1 in 7 reaching final interview were hired.",
        "k": 1,
        "n": 7,
        "sources": [
          {
            "title": "CEA hiring data",
            "url": "https://example.org/cea"
          }
        ]
      }
    ]
  },
  "key": "newco",
  "label": "NewCo offer"
}
```

Response 201:

```json
{
  "data": {
    "added": "newco",
    "code": "p30-2",
    "items": [
      {
        "key": "govai",
        "label": "GovAI offer"
      },
      {
        "key": "newco",
        "label": "NewCo offer"
      }
    ]
  }
}
```

### POST /api/v1/sandbox/runs/{id}/questions/preview

Check a run question's spec without creating it. Auth: read scope.

Request body:

```json
{
  "spec": {
    "anchors": [
      {
        "by": "2002-06",
        "p": 0.3
      },
      {
        "by": "2003-12",
        "p": 0.6
      }
    ],
    "basis": {
      "reference_classes": [
        {
          "description": "Announced widget factories that opened",
          "evidence": "Invented: 6 of 20 announced factories opened within two years.",
          "k": 6,
          "n": 20,
          "period": "1990-2000",
          "sources": []
        }
      ]
    },
    "horizon": {
      "end": "2003-12"
    },
    "question": "when",
    "resolution": {
      "details": "A factory counts once it ships its first widget.",
      "source": "Reported by the widget trade gazette"
    },
    "title": "Date the first widget factory opens"
  }
}
```

Response 200: too long to show here; see the example in the contract.

### POST /api/v1/sandbox/runs/{id}/questions/{code}/moderation

Accept or reject a run question (curriculum owners). Auth: write scope.

Request body:

```json
{
  "accepted": true,
  "note": "Clear and resolvable."
}
```

Response 200:

```json
{
  "data": {
    "code": "p680-1",
    "moderation": {
      "note": "Clear and resolvable.",
      "status": "accepted"
    }
  }
}
```

### POST /api/v1/sandbox/runs/{id}/questions/{code}/resolutions

Resolve a run question's bars. Auth: write scope.

Request body:

```json
{
  "resolutions": [
    {
      "outcome": "yes",
      "resolves_at": "cp4",
      "submarket_id": 45553
    }
  ]
}
```

Response 200:

```json
{
  "data": {
    "code": "p685-1",
    "resolved": 1
  }
}
```

### GET /api/v1/sandbox/planner_jobs/{job_id}

A client planner's job: the brief. Auth: write scope.

The planner job of a client-planned turn: the versioned job (`contract`), instructions, the events it may declare, the plan's schema, info questions, the planner notes and any revealed draws. The job id is the token take_turn returned with `status: "planner_job"` for a run with `planner: "client"`; only the run's owner can use it. Errors: 404 (no such job), 409 `job_drawn` / `job_submitted` / `job_closed`, 410 `job_expired` (take the turn again with the same turn_id). A wrap-up job (`kind: wrapup`, from POST /api/v1/sandbox/runs/{id}/wrapup) has `facts` and `instructions` instead of a turn: nothing to draw or plan. A questions job (`kind: questions`, from take_turn's `questions_needed` before the first turn) has `scenario`, `question_set` (`tracked`, `opening`, `aim`) and `instructions`: the planner makes the run's questions with POST /api/v1/sandbox/runs/{id}/questions.

Response 200:

```json
{
  "data": {
    "contract": "antistatic.planner-job/1",
    "events": [],
    "job": {},
    "job_id": "pj_x",
    "plan_schema": {},
    "planner_notes": null,
    "status": "open"
  }
}
```

### POST /api/v1/sandbox/planner_jobs/{job_id}/draws

Declare a turn's uncertain events and reveal their draws. Auth: write scope.

Declares every uncertain event of the turn (`events`: `{tool, input}`, tool draw, sample, curveball, adjust, settle_item, create_question, add_item, extend_question or ask_player, each with p and basis as its schema says). The server draws them all with the turn's seed, rolls the run questions and returns the results. A malformed batch returns 422 invalid_params with validation errors, reveals no draws or question rolls and leaves the job open for correction. An exact retry of an accepted batch returns its stored reveal; a changed batch cannot reroll. The job id is the token take_turn returned with `status: "planner_job"` for a run with `planner: "client"`; only the run's owner can use it. Errors: 404 (no such job), 409 `job_drawn` / `job_submitted` / `job_closed`, 410 `job_expired` (take the turn again with the same turn_id).

Request body:

```json
{
  "events": [
    {
      "input": {
        "basis": "Replies within a month: 4 of 10 past emails",
        "label": "The grant officer replies",
        "p": 0.4,
        "visible": true
      },
      "tool": "draw"
    }
  ]
}
```

Response 200:

```json
{
  "data": {
    "question_rolls": [],
    "results": [
      {
        "error": false,
        "result": {
          "label": "The grant officer replies",
          "n": 1,
          "result": "no"
        },
        "tool": "draw"
      }
    ]
  }
}
```

### POST /api/v1/sandbox/planner_jobs/{job_id}/plan

Commit a client planner's plan. Auth: write scope.

The plan (`plan`: branches with weights, observations, action outcomes and sealed updates, consistent with the revealed draws; optional info_answers and questions_for_player; in a game with other players, an observation's optional `audience`: the character keys who alone see it; an observation's optional `as_of`, the date its contents are measured at, on or before its visible_at). Validated like the server planner's; an observation dated outside the turn's window, with as_of after visible_at, or whose text names a later date than its visible_at returns 422 `invalid_plan` with `problems` (`field`, `value`, `problem`) and leaves the job open for correction; the server samples the branch from the seed, builds the script in code, moves the clock and returns the turn's result. Once per job. The job id is the token take_turn returned with `status: "planner_job"` for a run with `planner: "client"`; only the run's owner can use it. Errors: 404 (no such job), 409 `job_drawn` / `job_submitted` / `job_closed`, 410 `job_expired` (take the turn again with the same turn_id).

Request body:

```json
{
  "plan": {
    "assumptions": [],
    "branches": [],
    "evidence": []
  }
}
```

Response 200:

```json
{
  "data": {
    "mode": "client",
    "replayed": false,
    "run_id": 712,
    "script": "…"
  }
}
```

### POST /api/v1/sandbox/planner_jobs/{job_id}/script

Replace a client-planned turn's story with the client's prose. Auth: write scope.

The client's prose for the committed turn (`script`): it replaces the code-built story; the header, Results, Markets and Questions stay the server's. Days outside the turn are refused. The job id is the token take_turn returned with `status: "planner_job"` for a run with `planner: "client"`; only the run's owner can use it. Errors: 404 (no such job), 409 `job_drawn` / `job_submitted` / `job_closed`, 410 `job_expired` (take the turn again with the same turn_id). For a wrap-up job, `script` is the wrap-up's arc (once); the response has `wrapup`.

Request body:

```json
{
  "script": "On Tuesday you send the email."
}
```

Response 200:

```json
{
  "data": {
    "script": "**2026-10-12 to 2026-10-18 · T+6 days**\n\nOn Tuesday you send the email.\n\n…"
  }
}
```

### GET /api/v1/sandbox/planner_jobs/{job_id}/notes

Read a client planner's sealed notes. Auth: write scope.

The run's planner notes, a sealed scratchpad carried across turns; readable with a job of the run that is open or done within the day. The job id is the token take_turn returned with `status: "planner_job"` for a run with `planner: "client"`; only the run's owner can use it. Errors: 404 (no such job), 409 `job_drawn` / `job_submitted` / `job_closed`, 410 `job_expired` (take the turn again with the same turn_id).

Response 200:

```json
{
  "data": {
    "notes": "Commitments: …"
  }
}
```

### PUT /api/v1/sandbox/planner_jobs/{job_id}/notes

Replace a client planner's sealed notes. Auth: write scope.

Replaces the run's planner notes (`notes`, at most 20,000 characters). The job id is the token take_turn returned with `status: "planner_job"` for a run with `planner: "client"`; only the run's owner can use it. Errors: 404 (no such job), 409 `job_drawn` / `job_submitted` / `job_closed`, 410 `job_expired` (take the turn again with the same turn_id).

Request body:

```json
{
  "notes": "Commitments: …"
}
```

Response 200:

```json
{
  "data": {
    "notes": "Commitments: …"
  }
}
```

## Sandbox authoring

### GET /api/v1/sandbox/limits

Curriculum upload limits, field limits, lint rules and the T5 hindsight list. Auth: public.

The same numbers the server's changesets enforce; `antistatic sandbox check` applies them locally. Rule IDs are those of priv/sandboxes/RULES.md.

Response 200:

```json
{
  "data": {
    "fields": {
      "bar.key": {
        "max": 255
      }
    },
    "hindsight_terms": [
      "seminal",
      "would later"
    ],
    "manifest": {
      "max_bars": 300000,
      "max_bars_per_item": 3000,
      "max_bars_per_part": 6000,
      "max_checkpoints": 5000,
      "max_items": 1000,
      "max_part_bytes": 2000000,
      "max_parts": 400,
      "max_top_bytes": 6000000
    },
    "rules": [
      {
        "id": "T5",
        "severity": "warning",
        "summary": "Hindsight vocabulary (hindsight_terms) in learner-visible text."
      }
    ],
    "schema_versions": [
      2
    ]
  }
}
```

### PUT /api/v1/sandbox/curricula/{slug}/drafts/{version}/parts/{sha256}

Store one content-addressed part of a curriculum draft. Auth: write scope.

`sha256` is the hex SHA-256 of the exact body. 201 when stored, 200 (`stored: false`) when the draft already has it. Creates the draft if new. Parts hold compact items with their own disclosures and resolution requests; nothing is imported until the manifest arrives.

Request body:

```json
{
  "items": [
    {
      "key": "b001"
    }
  ]
}
```

Response 201:

```json
{
  "data": {
    "bars": 24,
    "bytes": 5120,
    "items": 1,
    "sha256": "a100000000000000000000000000000000000000000000000000000000000000",
    "stored": true
  }
}
```

### PUT /api/v1/sandbox/curricula/{slug}/drafts/{version}/manifest

Send a draft's manifest (top level and ordered part hashes) and start its import. Auth: write scope.

202 starts a background import (one part per transaction); poll status. 200 when the draft already has this manifest. 409 `draft_changed` when `base_sha256` is not the draft's current manifest; 422 `missing_parts` lists the hashes to send. Sending the manifest again resumes a failed or interrupted import.

Request body:

```json
{
  "base_sha256": null,
  "manifest_sha256": "9f2c000000000000000000000000000000000000000000000000000000000000",
  "parts": [
    "a100000000000000000000000000000000000000000000000000000000000000",
    "b200000000000000000000000000000000000000000000000000000000000000"
  ],
  "schema_version": 2,
  "source": {
    "commit": "5b2e6c65b",
    "dirty": false
  },
  "top": {
    "checkpoints": [
      {
        "key": "cp000"
      }
    ],
    "curriculum": {
      "title": "Biology"
    },
    "disclosures": []
  }
}
```

Response 202:

```json
{
  "data": {
    "error": null,
    "manifest_sha256": "9f2c000000000000000000000000000000000000000000000000000000000000",
    "parts_expected": 2,
    "parts_imported": 0,
    "parts_received": 2,
    "received": [
      "a100000000000000000000000000000000000000000000000000000000000000",
      "b200000000000000000000000000000000000000000000000000000000000000"
    ],
    "schema_version": 2,
    "slug": "biology",
    "source_commit": "5b2e6c65b",
    "state": "importing",
    "version": "1.1"
  }
}
```

### GET /api/v1/sandbox/curricula/{slug}/drafts/{version}/status

A curriculum version's push and import status. Auth: write scope.

Counts and hashes only. `error` has a code and a path; its message only for owners and editors who are not learners of the curriculum.

Response 200:

```json
{
  "data": {
    "error": null,
    "manifest_sha256": "9f2c000000000000000000000000000000000000000000000000000000000000",
    "parts_expected": 2,
    "parts_imported": 2,
    "parts_received": 2,
    "received": [
      "a100000000000000000000000000000000000000000000000000000000000000",
      "b200000000000000000000000000000000000000000000000000000000000000"
    ],
    "schema_version": 2,
    "slug": "biology",
    "source_commit": "5b2e6c65b",
    "state": "imported",
    "version": "1.1"
  }
}
```

### POST /api/v1/sandbox/curricula/{slug}/drafts/{version}

Start a curriculum draft built a piece at a time. Auth: write scope.

Makes the curriculum when the slug is new (a first curriculum is free; more need Pro, 403 `pro_required`; non-admins' curricula stay `restricted`) and starts, or reopens, the version's draft with an empty working manifest. `title`, `description` (plain text) and `resolved_through` (a date) are optional. A draft already pushed with a manifest is 409 `pushed_draft`. Then add checkpoints, questions and disclosures and validate: `validate` imports the working manifest first. Limits: 40 questions, 60 checkpoints, 200 disclosures; push larger curricula.

Request body:

```json
{
  "description": "From the first worm papers to the first trials.",
  "title": "RNAi, 1998-2006"
}
```

Response 201:

```json
{
  "data": {
    "authoring": {
      "checkpoints": [],
      "disclosures": [],
      "questions": []
    },
    "error": null,
    "manifest_sha256": null,
    "parts_expected": 0,
    "parts_imported": 0,
    "parts_received": 0,
    "received": [],
    "schema_version": null,
    "slug": "rnai-history",
    "source_commit": null,
    "state": "receiving",
    "version": "1.0"
  }
}
```

### POST /api/v1/sandbox/curricula/{slug}/drafts/{version}/checkpoints

Add or replace a checkpoint in a draft's working manifest. Auth: write scope.

`key`, `title` and `occurred_at` (a date or ISO date-time), optional `body_html`. An entry with the same key replaces the earlier one. `sequence` follows `occurred_at`, then the order added. Returns the draft status (`receiving`).

Request body:

```json
{
  "key": "cp01",
  "occurred_at": "1998-02-19",
  "title": "February 1998"
}
```

Response 200:

```json
{
  "data": {
    "authoring": {
      "checkpoints": [
        "cp01"
      ],
      "disclosures": [],
      "questions": []
    },
    "error": null,
    "manifest_sha256": null,
    "parts_expected": 0,
    "parts_imported": 0,
    "parts_received": 0,
    "received": [],
    "schema_version": null,
    "slug": "rnai-history",
    "source_commit": null,
    "state": "receiving",
    "version": "1.0"
  }
}
```

### POST /api/v1/sandbox/curricula/{slug}/drafts/{version}/questions

Add or replace a question (one compact-manifest item) in a draft. Auth: write scope.

One item in the compact format (`/sandbox/manifest.md`). Its shape is checked now (422 with a `path`, never a value); the publish rules run on validate. An item with the same key replaces the earlier one.

Request body:

```json
{
  "curves": [
    {
      "closes_at": "cp02",
      "keys": [
        "y2002",
        "y2004"
      ],
      "outcomes": [
        false,
        true
      ],
      "p0": [
        0.1,
        0.3
      ],
      "resolves_at": [
        "cp02",
        "cp03"
      ],
      "thresholds": [
        "2002-12-31T23:59:59Z",
        "2004-12-31T23:59:59Z"
      ]
    }
  ],
  "introduced_at": "cp01",
  "key": "q01",
  "market": {
    "budget": 100,
    "code": "rnai-trial",
    "resolution_criteria": "A registered trial of an RNAi therapeutic doses its first patient.",
    "type": "date",
    "x_unit": "date"
  },
  "title": "First RNAi therapy enters human trials"
}
```

Response 200:

```json
{
  "data": {
    "authoring": {
      "checkpoints": [
        "cp01",
        "cp02",
        "cp03"
      ],
      "disclosures": [],
      "questions": [
        "q01"
      ]
    },
    "error": null,
    "manifest_sha256": null,
    "parts_expected": 0,
    "parts_imported": 0,
    "parts_received": 0,
    "received": [],
    "schema_version": null,
    "slug": "rnai-history",
    "source_commit": null,
    "state": "receiving",
    "version": "1.0"
  }
}
```

### POST /api/v1/sandbox/curricula/{slug}/drafts/{version}/disclosures

Add or replace a disclosure in a draft's working manifest. Auth: write scope.

`key`, `checkpoint`, `title` and `body_html`; optional `item` (a question key), `kind` (`evidence`, `result`, `admin_explanation`) and `source_url` (an archived link, RULES.md S2). An entry with the same key replaces the earlier one.

Request body:

```json
{
  "body_html": "<p>Double-stranded RNA silences genes in worms.</p>",
  "checkpoint": "cp01",
  "item": "q01",
  "key": "d01",
  "kind": "evidence",
  "title": "Fire and Mello in Nature"
}
```

Response 200:

```json
{
  "data": {
    "authoring": {
      "checkpoints": [
        "cp01",
        "cp02",
        "cp03"
      ],
      "disclosures": [
        "d01"
      ],
      "questions": [
        "q01"
      ]
    },
    "error": null,
    "manifest_sha256": null,
    "parts_expected": 0,
    "parts_imported": 0,
    "parts_received": 0,
    "received": [],
    "schema_version": null,
    "slug": "rnai-history",
    "source_commit": null,
    "state": "receiving",
    "version": "1.0"
  }
}
```

### POST /api/v1/sandbox/curricula/{slug}/drafts/{version}/validate

Validate an imported draft: publish rules (errors) and lint (warnings tagged with RULES.md IDs). Auth: write scope.

A draft built a piece at a time (`POST drafts/{version}` and its checkpoints, questions and disclosures) is imported first when it has changed: 422 with a `path` when it lacks checkpoints or questions or its import fails. A learner of the curriculum gets counts only (`errors` redacted, `lint` empty, `lint_counts` kept).

Response 200:

```json
{
  "data": {
    "errors": [],
    "lint": [
      {
        "rule": "T5",
        "term": "eventually",
        "where": "items[b012].title"
      }
    ],
    "lint_counts": {
      "T5": 1
    },
    "unresolved_past_bar_count": 0,
    "valid": true,
    "warnings": []
  }
}
```

### POST /api/v1/sandbox/curricula/{slug}/drafts/{version}/publish

Publish an imported, valid draft atomically. Auth: write scope.

Response 200:

```json
{
  "data": {
    "manifest_sha256": "9f2c000000000000000000000000000000000000000000000000000000000000",
    "published_at": "2026-10-10T20:00:00Z",
    "slug": "biology",
    "status": "published",
    "version": "1.1"
  }
}
```

### DELETE /api/v1/sandbox/curricula/{slug}/drafts/{version}

Delete a curriculum draft and its staged parts. Auth: write scope.

Response 200:

```json
{
  "data": {
    "deleted": true
  }
}
```

### POST /api/v1/sandbox/curricula/{slug}/members

Make an account (username or email) a learner, editor or owner of a curriculum. Auth: write scope.

Bots are refused, and an account with a run in the curriculum cannot be an editor or owner.

Request body:

```json
{
  "role": "learner",
  "user": "nonstationary"
}
```

Response 200:

```json
{
  "data": {
    "role": "learner",
    "user_id": 42,
    "username": "nonstationary"
  }
}
```

### DELETE /api/v1/sandbox/curricula/{slug}/members/{user_id}

Remove an account from a curriculum. Auth: write scope.

Response 200:

```json
{
  "data": {
    "removed": true
  }
}
```

### POST /api/v1/sandbox/curricula/{slug}/versions/{version}/items

Add new questions to a published curriculum release mid-run. Auth: write scope.

Request body:

```json
{
  "items": [
    {
      "introduced_at": "cp2",
      "key": "p",
      "market": {
        "budget": "100",
        "code": "wl4642p",
        "cumulative": true,
        "resolution_criteria": "First gadget shipped.",
        "type": "date",
        "x_unit": "date"
      },
      "submarkets": [
        {
          "closes_at": "cp3",
          "end_date": "2001-12-31T00:00:00Z",
          "initial_probability": "0.5",
          "key": "dec",
          "resolved_yes": true,
          "resolves_at": "cp3",
          "threshold_date": "2001-12-31T00:00:00Z"
        },
        {
          "closes_at": "cp4",
          "end_date": "2002-12-31T00:00:00Z",
          "initial_probability": "0.8",
          "key": "y02",
          "resolved_yes": true,
          "resolves_at": "cp4",
          "threshold_date": "2002-12-31T00:00:00Z"
        }
      ],
      "title": "When does gadget p ship?"
    }
  ]
}
```

Response 200:

```json
{
  "data": {
    "added": [
      {
        "appended_at": "2026-10-10T19:31:15Z",
        "key": "p",
        "title": "When does gadget p ship?"
      }
    ],
    "slug": "retro-4674",
    "status": "published",
    "unresolved_past_bar_count": 0,
    "version": "0.1",
    "warnings": []
  }
}
```

### GET /api/v1/sandbox/curricula/{slug}/runs

Runs of a curriculum you author. Auth: write scope.

Response 200:

```json
{
  "data": [
    {
      "attempt": 1,
      "checkpoint_at": "2001-09-01T00:00:00Z",
      "checkpoint_key": "cp2",
      "checkpoint_sequence": 2,
      "id": 690,
      "learner": "fbhvkvm",
      "status": "active",
      "version": "0.1"
    }
  ]
}
```

### POST /api/v1/sandbox/curricula/{slug}/versions/{version}/resolutions

Record outcomes for a curriculum version's bars. Auth: write scope.

Request body:

```json
{
  "resolutions": [
    {
      "item": "when",
      "outcome": "yes",
      "resolves_at": "cp4",
      "submarket": "y04"
    }
  ]
}
```

Response 200:

```json
{
  "data": {
    "resolved": 1,
    "slug": "widget-open-86242"
  }
}
```

### POST /api/v1/sandbox/curricula/{slug}/versions/{version}/items/{code}/extend

Widen a published curriculum question's thresholds. Auth: write scope.

For curriculum owners and editors: widens a published how-much question's thresholds in place (`code`: its code or item key), as adding questions to a published release does. `thresholds` `{to, from}`: a new top and/or bottom, the ladder continued at its scale and spacing; `anchors`: as a market spec's (`[{period, quantiles: {10, 50, 90}}]`, periods by end date) to price the new bars, joined onto each period's end bar; each house reprice of the question prices them too. New bars settle from the question's count observations (at the end bar's resolves_at, or the observation's if later), else from the end bar's outcome (NO past a miss, YES below a reach), else from `values` (`{period: value}`, the period's recorded value); never drawn. In a release with `resolved_through` an undecided bar stays open (listed in `needs_value`, resolved through `resolutions`); otherwise the period needs a value. Every published minor of the major that has the question gets the same bars (`releases`). Runs see them from their next read; positions, forecasts and scores don't change. Each period keeps its liquidity. The release's structure now includes them, so a later minor of the same major must carry `bars` (manifest form). Later periods and when-questions change in a new major release. `preview: true` checks without changing anything.

Request body:

```json
{
  "anchors": [
    {
      "period": "2002-12-31",
      "quantiles": {
        "10": 10,
        "50": 22,
        "90": 42
      }
    }
  ],
  "preview": true,
  "thresholds": {
    "to": 50
  }
}
```

Response 200:

```json
{
  "data": {
    "added": 1,
    "bars": [
      {
        "closes_at": "d2002",
        "end_date": "2002-12-31T00:00:00Z",
        "group": "y2002",
        "key": "y2002-40",
        "p0": 0.12,
        "resolution": "prepared",
        "resolved_yes": true,
        "resolves_at": "d2002",
        "threshold": 40.0
      }
    ],
    "code": "widgets",
    "needs_value": [],
    "preview": true,
    "releases": 1,
    "settled": 1,
    "slug": "widget-counts",
    "version": "1.0"
  }
}
```

## Admin

### POST /api/v1/uploads

Upload an analysis asset (image, script or zip) for a market. Multipart. Auth: admin scope.

Request body:

```json
{
  "file": "(upload)"
}
```

Response 200:

```json
{
  "data": {
    "content_type": "text/plain",
    "filename": "fixture.txt",
    "id": "4f57f553-dd01-4ea7-ae44-857f5a7e180a",
    "state": "pending",
    "url": "http://localhost:4002/attachments/4f57f553-dd01-4ea7-ae44-857f5a7e180a"
  }
}
```

### GET /api/v1/admin/sandbox/comment-reviews

Pending shared sandbox comments (contamination review queue), oldest first, with automatic flags: later_date:<text>, link_unverified:<url> (not returned by the library before the comment's sandbox day, which applies the archive's date gate and exclusion lists), library_unavailable. Auth: admin scope.

Query: `limit`, `sandbox`.

Example query: `?limit=10`

Response 200:

```json
{
  "data": {
    "pending": [
      {
        "author": "sam",
        "flags": [],
        "id": 101,
        "market_code": "rt123",
        "market_title": "Paper A primary endpoint",
        "posted_at": "2026-10-12T15:00:00Z",
        "sandbox": "biology-2020",
        "sandbox_date": "2020-01-01",
        "text": "Effect looks real."
      }
    ]
  }
}
```

### POST /api/v1/admin/sandbox/comment-reviews/approve

Approve pending shared sandbox comments: other players whose clock is at or past each comment's sandbox date (and who show other players' comments) then see it. Auth: admin scope.

Request body:

```json
{
  "ids": [
    101
  ]
}
```

Response 200:

```json
{
  "data": {
    "approved": 1
  }
}
```

### POST /api/v1/admin/sandbox/comment-reviews/reject

Reject pending shared sandbox comments with a reason the author sees. Auth: admin scope.

Request body:

```json
{
  "ids": [
    101
  ],
  "reason": "Names a result from 2021."
}
```

Response 200:

```json
{
  "data": {
    "rejected": 1
  }
}
```

### POST /api/v1/admin/sandbox/{slug}/ante-comments

Post a library item as Ante's (bot) comment on a market of a past sandbox, approved and stamped at the end of the item's day; posted once per sandbox, market and doc_id. Auth: admin scope.

Request body:

```json
{
  "date": "2020-03-15",
  "doc_id": "newsrooms:7",
  "market_code": "rt123",
  "title": "Trial enrols its last patient",
  "why": "Enrolment closing sets when the endpoint can read out."
}
```

Response 200:

```json
{
  "data": {
    "comment_id": 102,
    "posted": true
  }
}
```

### POST /api/v1/admin/sandbox/{slug}/ante-curate

Run the first (placeholder) curator over a past sandbox: for each checkpoint, the library feed since the previous one, each item posted as Ante's comment on the open question sharing the most title words (at least min_overlap). Auth: admin scope.

Request body:

```json
{
  "dry_run": true
}
```

Response 200:

```json
{
  "data": {
    "existing": 0,
    "picks": [
      {
        "date": "2020-02-01",
        "doc_id": "n:1",
        "market_code": "rt123",
        "why": "May bear on \"Paper A primary endpoint\": it mentions endpoint, paper, primary."
      }
    ],
    "posted": 0,
    "windows": 3
  }
}
```

### POST /api/v1/markets

Create a market from CSV-style rows (admin). Auth: admin scope.

Request body:

```json
{
  "budget": "100",
  "code": "ct-made5539",
  "resolution_criteria": "Criteria",
  "status": "draft",
  "title": "Made over the API",
  "type": "date",
  "visibility": "private",
  "x_unit": "date"
}
```

Response 201:

```json
{
  "data": {
    "background_info_html": null,
    "budget": 100.0,
    "code": "~6655",
    "cumulative": true,
    "end_date": null,
    "following": true,
    "id": 6655,
    "inserted_at": "2026-10-10T19:44:29Z",
    "local_code": "ct-made5539",
    "reference": "~6655",
    "resolution_criteria": "Criteria",
    "status": "draft",
    "subscribed_to_comments": true,
    "title": "Made over the API",
    "type": "date",
    "visibility": "private",
    "x_unit": "date"
  }
}
```

### POST /api/v1/markets/{code}/liquidity

Set a market's liquidity (admin). Auth: admin scope.

Request body:

```json
{
  "mode": "multiply",
  "value": "2"
}
```

Response 200:

```json
{
  "data": {
    "mode": "multiply",
    "total_after": 100.0,
    "total_before": 50.0,
    "updated_submarkets": 1,
    "value": 2.0
  }
}
```

### GET /api/v1/markets/{code}/staging

A market's staged draft (admin). Auth: admin scope.

Response 200:

```json
{
  "data": {
    "draft": {
      "background_info_html": null,
      "changes": [],
      "id": 61,
      "market_id": 6654,
      "number_format": null,
      "resolution_criteria": "Revised criteria",
      "svelte_params": null,
      "target_major": 1,
      "target_minor": 0,
      "updated_at": "2026-10-10T19:44:28Z",
      "updated_by_id": 10469,
      "x_unit": null
    }
  }
}
```

### PUT /api/v1/markets/{code}/staging

Create or update a market's staged draft (admin). Auth: admin scope.

Request body:

```json
{
  "resolution_criteria": "Revised criteria"
}
```

Response 200:

```json
{
  "data": {
    "background_info_html": null,
    "changes": [],
    "id": 61,
    "market_id": 6654,
    "number_format": null,
    "resolution_criteria": "Revised criteria",
    "svelte_params": null,
    "target_major": 1,
    "target_minor": 0,
    "updated_at": "2026-10-10T19:44:28Z",
    "updated_by_id": 10469,
    "x_unit": null
  }
}
```

### POST /api/v1/markets/{code}/staging/probability-update

Stage starting-probability changes and new bars (admin). Auth: admin scope.

Request body:

```json
{
  "additions": [
    {
      "initial_probability": "0.3",
      "label": "By Feb 2029",
      "projection_group": "main",
      "threshold_date": "2029-02-28T23:59:59Z"
    }
  ]
}
```

Response 200:

```json
{
  "data": {
    "background_info_html": null,
    "changes": [
      {
        "action": "add",
        "budget_allocation": null,
        "end_date": "2029-02-28T23:59:59Z",
        "existing_submarket_id": null,
        "id": 42,
        "initial_probability": 0.3,
        "label": "By Feb 2029",
        "projection_group": "main",
        "threshold_date": "2029-02-28T23:59:59Z",
        "threshold_decimal": null
      }
    ],
    "id": 46,
    "market_id": 5113,
    "number_format": null,
    "resolution_criteria": null,
    "svelte_params": null,
    "target_major": 1,
    "target_minor": 0,
    "updated_at": "2026-10-10T19:17:57Z",
    "updated_by_id": 7940,
    "x_unit": null
  },
  "warnings": []
}
```

### POST /api/v1/markets/{code}/staging/publish

Publish a market's staged draft (admin). Auth: admin scope.

Response 200:

```json
{
  "data": {
    "market": {
      "background_info_html": null,
      "budget": 120.0,
      "code": "de12680",
      "cumulative": true,
      "end_date": null,
      "id": 5113,
      "inserted_at": "2026-10-10T19:17:57Z",
      "local_code": "de12680",
      "reference": "de12680",
      "resolution_criteria": "Criteria",
      "status": "draft",
      "title": "Date end default market",
      "type": "date",
      "visibility": "public",
      "x_unit": "date"
    },
    "trading_status": "draft",
    "version": {
      "change_summary": null,
      "external_commit_url": null,
      "id": 764,
      "major_version": 1,
      "minor_version": 0,
      "version_kind": "published"
    },
    "version_major": 1,
    "version_minor": 0
  }
}
```

### POST /api/v1/markets/{code}/staging/discard

Discard a market's staged draft (admin). Auth: admin scope.

Response 200:

```json
{
  "data": {
    "status": "ok"
  }
}
```

### POST /api/v1/markets/{code}/liquidity-adjustments/preview

Preview a liquidity adjustment (admin). Auth: admin scope.

Request body:

```json
{
  "categories": [
    "g01"
  ],
  "reduction_pct": "50",
  "scheduled_for": "2026-10-10T19:16:25Z"
}
```

Response 200:

```json
{
  "data": {
    "actual_pct": 24.9999,
    "after_max_points": 51.911,
    "before_max_points": 69.2146,
    "categories": [
      "g01"
    ],
    "multiplier": 0.5,
    "per_category": [
      {
        "affected": true,
        "after_max_points": 17.3037,
        "before_max_points": 34.6073,
        "category": "g01"
      },
      {
        "affected": false,
        "after_max_points": 34.6073,
        "before_max_points": 34.6073,
        "category": "g02"
      }
    ],
    "per_submarket": [
      {
        "affected": true,
        "after_max_points": 17.3037,
        "before_max_points": 34.6073,
        "label": "g01 at least 10",
        "multiplier": 0.5,
        "projection_group": "g01",
        "submarket_id": 35269
      },
      {
        "affected": false,
        "after_max_points": 34.6073,
        "before_max_points": 34.6073,
        "label": "g02 at least 10",
        "multiplier": 1.0,
        "projection_group": "g02",
        "submarket_id": 35270
      }
    ],
    "reduction_pct": 50.0,
    "scheduled_for": "2026-10-10T19:16:25Z",
    "warnings": [
      "scheduled_for is in the past; apply-now may be more appropriate"
    ]
  }
}
```

### POST /api/v1/markets/{code}/liquidity-adjustments/schedule

Schedule a liquidity adjustment (admin). Auth: admin scope.

Request body:

```json
{
  "reduction_pct": "10",
  "scheduled_for": "2026-10-11T19:44:28Z",
  "type": "proactive"
}
```

Response 200: too long to show here; see the example in the contract.

### GET /api/v1/markets/{code}/liquidity-adjustments

A market's liquidity adjustments (admin). Auth: admin scope.

Response 200:

```json
{
  "data": [
    {
      "applied_at": "2026-10-10T19:17:26Z",
      "categories": [
        "g01"
      ],
      "created_by_id": 7517,
      "description": "Retroactive liquidity reduction of 50% max points in category g01",
      "id": 155,
      "inserted_at": "2026-10-10T19:17:26Z",
      "kind": "max_points_reduction",
      "market_id": 4916,
      "multiplier": 0.5,
      "notes": null,
      "reduction_pct": 50.0,
      "scheduled_for": "2026-10-10T19:16:25Z",
      "status": "applied",
      "type": "retroactive"
    }
  ]
}
```

### POST /api/v1/markets/{code}/liquidity-adjustments/{id}/cancel

Cancel a scheduled liquidity adjustment (admin). Auth: admin scope.

Response 200:

```json
{
  "data": {
    "applied_at": null,
    "categories": null,
    "created_by_id": 10469,
    "description": "Scheduled liquidity reduction of 10% max points",
    "id": 227,
    "inserted_at": "2026-10-10T19:44:28Z",
    "kind": "max_points_reduction",
    "market_id": 6654,
    "multiplier": 0.9,
    "notes": null,
    "reduction_pct": 10.0,
    "scheduled_for": "2026-10-11T19:44:28Z",
    "status": "cancelled",
    "type": "proactive"
  }
}
```

### POST /api/v1/markets/{code}/liquidity-adjustments/{id}/apply-now

Apply a scheduled liquidity adjustment now (admin). Auth: admin scope.

Response 200:

```json
{
  "data": {
    "applied_at": "2026-10-10T19:44:29Z",
    "categories": null,
    "created_by_id": 10469,
    "description": "Scheduled liquidity reduction of 10% max points",
    "id": 228,
    "inserted_at": "2026-10-10T19:44:29Z",
    "kind": "max_points_reduction",
    "market_id": 6654,
    "multiplier": 0.9,
    "notes": null,
    "reduction_pct": 10.0,
    "scheduled_for": "2026-10-11T19:44:28Z",
    "status": "applied",
    "type": "proactive"
  }
}
```

### POST /api/v1/markets/{code}/historical-resolutions

Record historical resolutions for a market's bars (admin). Auth: admin scope.

Request body:

```json
{
  "entries": [
    {
      "projection_group": "2019",
      "resolved_value": "100"
    }
  ]
}
```

Response 200:

```json
{
  "data": {
    "entries": [
      {
        "action": "updated_resolution",
        "created_submarket_ids": [],
        "existing_submarket_ids": [
          37690
        ],
        "index": 0,
        "projection_group": "2019",
        "resolved_submarket_ids": [
          37690
        ]
      }
    ]
  }
}
```

### POST /api/v1/markets/{code}/activate-conditional-date

Activate a conditional date market (admin). Auth: admin scope.

Request body:

```json
{
  "deployed_at_utc": "2026-05-01T00:00:00Z"
}
```

Response 200:

```json
{
  "data": {
    "background_info_html": null,
    "budget": 1.0e3,
    "code": "cda20547",
    "cumulative": true,
    "end_date": "2026-08-13T00:00:00Z",
    "id": 5322,
    "inserted_at": "2026-10-10T19:18:15Z",
    "local_code": "cda20547",
    "reference": "cda20547",
    "resolution_criteria": "Conditional criteria",
    "status": "open",
    "title": "Activated title",
    "type": "date",
    "visibility": "public",
    "x_unit": "date"
  }
}
```

### POST /api/v1/markets/{code}/activate-conditional-multicount

Activate a condition of a conditional count market (admin). Auth: admin scope.

Request body:

```json
{
  "condition_key": "scenario-a"
}
```

Response 200:

```json
{
  "data": {
    "background_info_html": null,
    "budget": 1.0e3,
    "code": "mca17799",
    "cumulative": true,
    "end_date": "2027-10-10T19:18:01Z",
    "id": 5139,
    "inserted_at": "2026-10-10T19:18:01Z",
    "local_code": "mca17799",
    "reference": "mca17799",
    "resolution_criteria": "Conditional criteria",
    "status": "open",
    "title": "Activated multicount title",
    "type": "count",
    "visibility": "public",
    "x_unit": "units"
  }
}
```

## Meta

### GET /api/v1/contract.json

This contract: every /api/v1 route with its auth, request and response schemas and an example. Auth: public.

Public and secret-free. Served with an ETag; send `If-None-Match` for a 304.

Response 200:

```json
{
  "contract_version": "1",
  "routes": []
}
```


## Deprecated routes

These still work until their sunset date and answer with `Deprecation` and `Sunset` headers.

- `GET /api/v1/timelines`: use `GET /api/v1/sandbox` (sunset 2027-04-11).
- `GET /api/v1/timelines/runs/{id}`: use `GET /api/v1/sandbox/runs/{id}` (sunset 2027-04-11).
- `GET /api/v1/timelines/runs/{id}/sources`: use `GET /api/v1/sandbox/runs/{id}/sources` (sunset 2027-04-11).
- `GET /api/v1/timelines/runs/{id}/library`: use `GET /api/v1/sandbox/runs/{id}/library` (sunset 2027-04-11).
- `GET /api/v1/timelines/runs/{id}/turn_status`: use `GET /api/v1/sandbox/runs/{id}/turn_status` (sunset 2027-04-11).
- `POST /api/v1/timelines/runs`: use `POST /api/v1/sandbox/runs` (sunset 2027-04-11).
- `PATCH /api/v1/timelines/runs/{id}/setup`: use `PATCH /api/v1/sandbox/runs/{id}/setup` (sunset 2027-04-11).
- `POST /api/v1/timelines/runs/{id}/turns`: use `POST /api/v1/sandbox/runs/{id}/turns` (sunset 2027-04-11).
- `POST /api/v1/timelines/runs/{id}/forks`: use `POST /api/v1/sandbox/runs/{id}/forks` (sunset 2027-04-11).
- `DELETE /api/v1/timelines/runs/{id}`: use `DELETE /api/v1/sandbox/runs/{id}` (sunset 2027-04-11).
- `POST /api/v1/timelines/runs/{id}/questions/{code}/promote`: use `POST /api/v1/sandbox/runs/{id}/questions/{code}/promote` (sunset 2027-04-11).
- `GET /api/v1/timelines/limits`: use `GET /api/v1/sandbox/limits` (sunset 2027-04-11).
- `PUT /api/v1/timelines/curricula/{slug}/drafts/{version}/parts/{sha256}`: use `PUT /api/v1/sandbox/curricula/{slug}/drafts/{version}/parts/{sha256}` (sunset 2027-04-11).
- `PUT /api/v1/timelines/curricula/{slug}/drafts/{version}/manifest`: use `PUT /api/v1/sandbox/curricula/{slug}/drafts/{version}/manifest` (sunset 2027-04-11).
- `GET /api/v1/timelines/curricula/{slug}/drafts/{version}/status`: use `GET /api/v1/sandbox/curricula/{slug}/drafts/{version}/status` (sunset 2027-04-11).
- `POST /api/v1/timelines/curricula/{slug}/drafts/{version}`: use `POST /api/v1/sandbox/curricula/{slug}/drafts/{version}` (sunset 2027-04-11).
- `POST /api/v1/timelines/curricula/{slug}/drafts/{version}/checkpoints`: use `POST /api/v1/sandbox/curricula/{slug}/drafts/{version}/checkpoints` (sunset 2027-04-11).
- `POST /api/v1/timelines/curricula/{slug}/drafts/{version}/questions`: use `POST /api/v1/sandbox/curricula/{slug}/drafts/{version}/questions` (sunset 2027-04-11).
- `POST /api/v1/timelines/curricula/{slug}/drafts/{version}/disclosures`: use `POST /api/v1/sandbox/curricula/{slug}/drafts/{version}/disclosures` (sunset 2027-04-11).
- `POST /api/v1/timelines/curricula/{slug}/drafts/{version}/validate`: use `POST /api/v1/sandbox/curricula/{slug}/drafts/{version}/validate` (sunset 2027-04-11).
- `POST /api/v1/timelines/curricula/{slug}/drafts/{version}/publish`: use `POST /api/v1/sandbox/curricula/{slug}/drafts/{version}/publish` (sunset 2027-04-11).
- `DELETE /api/v1/timelines/curricula/{slug}/drafts/{version}`: use `DELETE /api/v1/sandbox/curricula/{slug}/drafts/{version}` (sunset 2027-04-11).
- `POST /api/v1/timelines/curricula/{slug}/members`: use `POST /api/v1/sandbox/curricula/{slug}/members` (sunset 2027-04-11).
- `DELETE /api/v1/timelines/curricula/{slug}/members/{user_id}`: use `DELETE /api/v1/sandbox/curricula/{slug}/members/{user_id}` (sunset 2027-04-11).
- `POST /api/v1/timelines/curricula/{slug}/versions/{version}/items`: use `POST /api/v1/sandbox/curricula/{slug}/versions/{version}/items` (sunset 2027-04-11).
- `GET /api/v1/timelines/curricula/{slug}/runs`: use `GET /api/v1/sandbox/curricula/{slug}/runs` (sunset 2027-04-11).
- `POST /api/v1/timelines/curricula/{slug}/versions/{version}/resolutions`: use `POST /api/v1/sandbox/curricula/{slug}/versions/{version}/resolutions` (sunset 2027-04-11).
- `GET /api/v1/timelines/runs`: use `GET /api/v1/sandbox/runs` (sunset 2027-04-11).
- `POST /api/v1/timelines/runs/{id}/advance`: use `POST /api/v1/sandbox/runs/{id}/advance` (sunset 2027-04-11).
- `POST /api/v1/timelines/runs/{id}/resolutions`: use `POST /api/v1/sandbox/runs/{id}/resolutions` (sunset 2027-04-11).
- `GET /api/v1/timelines/runs/{id}/questions`: use `GET /api/v1/sandbox/runs/{id}/questions` (sunset 2027-04-11).
- `POST /api/v1/timelines/runs/{id}/questions`: use `POST /api/v1/sandbox/runs/{id}/questions` (sunset 2027-04-11).
- `POST /api/v1/timelines/runs/{id}/questions/preview`: use `POST /api/v1/sandbox/runs/{id}/questions/preview` (sunset 2027-04-11).
- `POST /api/v1/timelines/runs/{id}/questions/{code}/moderation`: use `POST /api/v1/sandbox/runs/{id}/questions/{code}/moderation` (sunset 2027-04-11).
- `POST /api/v1/timelines/runs/{id}/questions/{code}/resolutions`: use `POST /api/v1/sandbox/runs/{id}/questions/{code}/resolutions` (sunset 2027-04-11).
- `POST /api/v1/retrocasting/curricula/{slug}/versions/{version}/items`: use `POST /api/v1/sandbox/curricula/{slug}/versions/{version}/items` (sunset 2027-04-11).
- `GET /api/v1/retrocasting/curricula/{slug}/runs`: use `GET /api/v1/sandbox/curricula/{slug}/runs` (sunset 2027-04-11).
- `POST /api/v1/retrocasting/curricula/{slug}/versions/{version}/resolutions`: use `POST /api/v1/sandbox/curricula/{slug}/versions/{version}/resolutions` (sunset 2027-04-11).
- `GET /api/v1/retrocasting/runs`: use `GET /api/v1/sandbox/runs` (sunset 2027-04-11).
- `GET /api/v1/retrocasting/runs/{id}`: use `GET /api/v1/sandbox/runs/{id}` (sunset 2027-04-11).
- `POST /api/v1/retrocasting/runs/{id}/advance`: use `POST /api/v1/sandbox/runs/{id}/advance` (sunset 2027-04-11).
- `POST /api/v1/retrocasting/runs/{id}/resolutions`: use `POST /api/v1/sandbox/runs/{id}/resolutions` (sunset 2027-04-11).
- `GET /api/v1/retrocasting/runs/{id}/questions`: use `GET /api/v1/sandbox/runs/{id}/questions` (sunset 2027-04-11).
- `POST /api/v1/retrocasting/runs/{id}/questions`: use `POST /api/v1/sandbox/runs/{id}/questions` (sunset 2027-04-11).
- `POST /api/v1/retrocasting/runs/{id}/questions/preview`: use `POST /api/v1/sandbox/runs/{id}/questions/preview` (sunset 2027-04-11).
- `POST /api/v1/retrocasting/runs/{id}/questions/{code}/moderation`: use `POST /api/v1/sandbox/runs/{id}/questions/{code}/moderation` (sunset 2027-04-11).
- `POST /api/v1/retrocasting/runs/{id}/questions/{code}/resolutions`: use `POST /api/v1/sandbox/runs/{id}/questions/{code}/resolutions` (sunset 2027-04-11).

