# Talos raw query API — v1.0.0

**Frozen.** This document is the contract `packages/observe` (step 06) and the critic (steps 07–08)
are written against. It is complete and exact on purpose: an agent should be able to read it cold
and call every endpoint correctly without reading any Rust.

Served by `crates/app` from `crates/query`:

| | |
|---|---|
| HTTP | `http://127.0.0.1:7700` |
| WebSocket | `ws://127.0.0.1:7701/stream` |

Both ports are configurable (`argus_query::Config`). `/stream` is also mounted on the HTTP port, so
a client that only knows one address always finds it. Everything binds loopback only.

Those are the standalone defaults. A worker started for a workspace session binds ephemeral ports
and reports them on its ready line; a client resolves it by session id and never guesses a port.
See [Managed sessions](#managed-sessions).

## Versioning rule

**Additions are fine; changes need a reason in the commit.**

- **Additive, no version bump:** a new endpoint, a new field on a response, a new optional query
  parameter, a new variant of a tagged enum (`type`, `kind`, `status`), a new response header.
  Clients must ignore fields and variants they do not recognize.
- **Breaking, needs a version bump and a reason in the commit message:** removing or renaming a
  field, changing a field's type, changing a status code, changing the meaning of an existing
  value, making an optional request field required.

`API_VERSION` is in `crates/query/src/lib.rs`, on `GET /`, in the `x-argus-api` header of every
response, and in the `hello` message on `/stream`.

## Conventions

These hold on every endpoint, and `crates/query/tests/json_shape.rs` enforces them against real
responses rather than trusting the types.

- **Positions are arrays.** `[x, y, z]` in metres, never `{"x":…}`. Rotations are quaternions,
  `[x, y, z, w]`. Axis values are `[x, y]`.
- **Entities are named by `id` and `name`.** `id` is the stable scene id and is the key for
  `GET /entity/{id}`. `name` is the human label and is not unique. No ECS handle, slot index or
  generation counter ever crosses this boundary, so an id that worked at frame 100 still works at
  frame 900.
- **No NaN, no infinity.** A non-finite float is written as `0` and counted in
  `GET /perf` → `nonfinite_scrubbed`. A non-zero count means some subsystem produced a NaN.
- **No null where a number belongs.** Optional fields are *omitted*, never `null`. There are
  exactly two whole-body nulls in the API, both deliberate: a `POST /raycast` miss and a
  `POST /path` with no route. One *field* is also deliberately nullable: a HUD field a behavior
  cleared with `ctx.hud.set(key, null)` is published as `null` rather than dropped, because the
  key is still declared and a restore that dropped it could not put the cleared state back. See
  [`GET /snapshot`](#get-snapshot).
- **Every response is frame-stamped.** `x-argus-frame` carries the frame the response describes,
  so a capture, an event list and a trace can be correlated without guessing.
- **Every response is state-stamped.** `x-argus-stamp` carries the *committed state*, which is a
  different question from the frame and the one to compare when correlating. See
  [State stamps](#state-stamps).
- **Frames and time.** `frame` is the fixed-tick counter, 60 Hz, starting at 0. `time` is simulated
  seconds since the scene loaded.

### Response headers

| Header | On | Meaning |
|---|---|---|
| `x-argus-frame` | every response | the frame this response describes |
| `x-argus-stamp` | every response | the committed **state** this response describes, `artifact.frame.revision.structure` |
| `x-argus-api` | every response | the API version, `1.0.0` |
| `x-argus-trace-range` | `GET /trace` | `first-last` frame indices in this page |
| `x-argus-trace-truncated` | `GET /trace` | frames dropped from the oldest end to fit the budget |
| `x-argus-trace-deltas` | `GET /trace` | transform deltas in this page |
| `x-argus-total` | `GET /entities`, `GET /feel` | how many entities (for `/feel`, animators) matched, before the page cut them |
| `x-argus-next-cursor` | `GET /entities`, `GET /feel` | the `cursor` for the next page; absent on the last |

### Request headers

| Header | On | Meaning |
|---|---|---|
| `x-argus-session` | any request | the session id the request is meant for |
| `x-argus-nonce` | any request | the serving instance's nonce, from its ready line or the workspace service |

Both are optional against a standalone server, and checked whenever they are sent: an identity that
is not the server's own is refused on any route. See [Managed sessions](#managed-sessions).

### Errors

Every non-2xx response has the same body.

```json schema=ErrorBody
{ "error": "not_found", "message": "no entity with id \"nope\"; GET /entities lists what the scene has" }
```

| `error` | Status | Means |
|---|---|---|
| `bad_request` | 400 | the request parsed but a value is wrong: a non-finite float, a zero ray direction, a malformed filter, or (on `GET /events`, `/entities` and `/feel`) a query parameter the route does not take, which is refused rather than ignored so a misspelt filter cannot read as one that matched everything. The `message` names the field or the unknown parameter and the ones the route takes. |
| `not_found` | 404 | no such entity, schema, or route |
| `unsupported` | 501 | this build has not wired up the subsystem behind this endpoint. The `message` names the endpoint. |
| `timeout` | 503 | the simulation did not answer a queued command before the deadline: it is paused under a debugger, blocked on a load, or shut down |
| `unavailable` | 503 | the command queue is full, or the simulation dropped the command |
| `at_capacity` | 503 | a bounded resource is full and the server declined the work rather than queueing it: too many live `WS /stream` readers, or too many exact operations in flight. `GET /state` reports both counts and both ceilings. |
| `stale_stamp` | 409 | an exact request named a committed state this server cannot answer for: already passed, not yet reached, or belonging to a scene since reloaded. `wanted` and `current` carry both stamps. |
| `internal` | 500 | the source failed while answering |
| `session_mismatch` | 409 | the request presented an identity that is not this server's: another session's id (`"mismatch": "session"`), or a nonce another server instance issued (`"mismatch": "nonce"`), usually a process since replaced on the same port |
| `capability_denied` | 403 | the request is this session's, but the session does not grant what the endpoint needs. `capability` names it. |
| `session_required` | 428 | a managed session refused a request that presented no identity. `capability` names what the endpoint needs. |
| `host_rejected` | 403 | the `Host` header is not `127.0.0.1:<port>` or `localhost:<port>` for one of this server's two ports: a web page that re-pointed its own name at 127.0.0.1 (DNS rebinding), or a client that set `Host` itself. Every route, `GET /session` and `WS /stream` included. |
| `origin_rejected` | 403 | the request carries an `Origin` that is not `http://127.0.0.1:<port>` or `http://localhost:<port>`: a web page on another site. Programs send no `Origin`. This covers the `WS /stream` handshake, which browsers send cross-site without CORS. |
| `unsupported_media_type` | 415 | a POST body without `Content-Type: application/json`. |

`capability` and `mismatch` appear only on those three, and `wanted` and `current` only on
`stale_stamp`; every other error body is exactly `error` and `message`.

### State stamps

`x-argus-frame` answers *which tick*. For a world that only changes by ticking that is the whole
story, and for this engine it is not:

- A **paused** world still changes. A restore, a scene patch and an editor edit all mutate it
  without advancing the tick counter, so two responses can carry the same `frame` and describe
  different states.
- A **restart** puts the tick counter back to 0, so `frame` is not even monotonic.
- An **entity replaced at the same count** changes every published identity while `frame` advances
  exactly as it would have anyway.
- A **rendered frame** is not a state change at all. A world drawn at 1 kHz and simulated at 60 Hz
  commits 60 states a second and draws a thousand pictures of them.

So every response also carries `x-argus-stamp`, which names the committed state itself:

```text
x-argus-stamp: 1.130.457.3
```

Four counters, `artifact.frame.revision.structure`:

| | Changes when | What a client does about it |
|---|---|---|
| `artifact` | the applied scene changes: load, restart, hot reload, patch | drop every cached id, path and capture |
| `frame` | the simulation commits a tick | the same number as `x-argus-frame` |
| `revision` | **any** committed state change, ticked or not | compare this to ask "is this the state I already have" |
| `structure` | the published identity set may differ: spawn, despawn, replacement, restore | refetch `GET /entities` rather than reusing identities |

`revision` is monotonic for the life of the session and **does not reset on a restart**, so a client
that reconnects across one can still order what it saw. Two responses with the same `artifact` and
`revision` describe the same committed state.

The same stamp appears as an object in JSON, on `GET /state` and in the `hello` message on
`/stream`:

```json schema=StateStamp
{ "artifact": 1, "frame": 130, "revision": 457, "structure": 3 }
```

A server whose source does not stamp its publishes reports `0.{frame}.0.0`, which reads as "tick
`frame` of something that does not name states". `revision: 0` is the marker.

### Exact reads: `?at=`

Eight routes take an optional `at` parameter naming the committed state to answer from:
`GET /entities`, `GET /entity/{id}`, `GET /contacts`, `GET /perf`, `GET /snapshot`, `GET /ui`,
`GET /ui/screens`, and `POST /capture`.

```text
GET /entities?at=1.130.457.3
```

Absent, the read answers from whatever is current, which is what every read did before this existed
and what most reads still want. Present, there are exactly three outcomes, and **none of them is a
different state returned as though it were the one asked for**:

| | |
|---|---|
| the simulation is at that state, or reaches it before the deadline | `200`, and `x-argus-stamp` equals what was asked for |
| the state has been passed, or belongs to a scene since reloaded | `409 stale_stamp`, carrying `wanted` and `current` |
| the deadline passes with the state still ahead | `503 timeout` |

The typical sequence is to step and then read the state the step reached:

```text
POST /time {"paused": true, "step": 1}   ->  x-argus-stamp: 1.131.462.3
GET /entities?at=1.131.462.3             ->  the entities as of that tick, or an error
```

This is what makes an exact read exact across a throttled or fast render loop, across several ticks
per rendered frame, and across a reconnect: the answer is correlated to the state that was asked
for rather than to whenever the request happened to arrive.

An `at` value that is not a stamp is `400`, not ignored — ignoring it would answer an exact request
approximately.

### Ceilings

The server bounds what it will take on, and refuses past it with `503 at_capacity` rather than
accepting work that would degrade every reader:

| | Default ceiling | Refused with |
|---|---|---|
| live `WS /stream` readers | 32 | `at_capacity` on the upgrade, before the handshake completes |
| exact operations in flight | 8 | `at_capacity` on `GET /snapshot`, `POST /snapshot`, `POST /capture`, and any read carrying `?at=` |

**Read the numbers, do not hard-code them.** They are configurable
(`argus_query::Limits`, set through `LiveSource::with_limits`), and `GET /state` reports what this
server is actually holding itself to alongside how much of each is in use. A client that reads it
first decides whether to open another socket, rather than discovering the answer as a `503`.

Two further bounds have no dial on the wire because a client never has to think about them: the
entity-buffer pool is capped by bytes as well as by count, and a pooled buffer a reader is still
holding is released rather than occupying a slot nothing can reuse.

Two more bounds apply to readers and need nothing from the client. `perf` and `frame` messages are
**samples**, so a socket that falls behind is sent only the newest of each rather than a backlog of
superseded ones; events are never coalesced, because each is a thing that happened and `seq` is
gapless by contract. And a socket whose `send` cannot complete within two seconds is closed: at that
point nobody is reading it, and a reader nobody reads still holds a receiver.

### Entity-addressed requests

Three requests name an entity: `GET /entity/{id}`, `POST /capture` with `{"kind":"views"}`, and
`POST /input` for an action or axis that carries an `entity`. All three answer an id the scene does
not have the same way, with the same message, so the shape learned from one reads correctly on the
others:

```json schema=ErrorBody
{ "error": "not_found", "message": "no entity with id \"ghost\"; GET /entities lists what the scene has" }
```

An id that `GET /entity/{id}` refuses is refused by the other two as well, because all three are
answered from the same published snapshot. The refusal does not wait on the simulation.

An input action with no `entity` targets the scene's player spawn and is not checked, since there is
no id to check.

`501` is the one worth handling specially. The engine is built in separate pieces, and an endpoint whose
subsystem has not landed answers `501` with its own name rather than returning an empty object that
looks like an answer. Treat it as "not yet", not as "broken".

---

## `GET /entities`

Every entity passing the filter, in stable entity order.

| Parameter | Shape | Meaning |
|---|---|---|
| `id` | `a,b,coin#*` | the entity's id is one of these; an item with `*` or `?` is a pattern |
| `name` | pattern | the entity's name matches (`*` any run of characters, `?` exactly one) |
| `tag` | string | the entity carries this tag exactly |
| `component` | string | the entity carries a component of this `type` |
| `near` | `x,y,z,radius` | the entity's position is within `radius` metres of the point |
| `inside` | `minx,miny,minz,maxx,maxy,maxz` | the entity's position is inside this box, bounds included |
| `fields` | `transform,anim` | keep only these top-level fields of each entity; `id` is always kept |
| `limit` | integer | return at most this many |
| `cursor` | the last page's `x-argus-next-cursor` | start the page there |

Clauses are ANDed. No parameters means every entity. A malformed `near` or `inside` is a `400`
naming the parameter and the shape it wanted.

### Narrowing a large answer

On a scene of a few hundred entities the whole answer is megabytes, far more than an agent can
read, so it narrows three ways, which combine:

- **Which entities.** `id=` takes exact ids and patterns (`id=door,coin#*` is the door and every
  copy spawned from `coin`); `name=` is a pattern over names; `tag=`, `component=`, `near=` and
  `inside=` as above.
- **Which fields.** `fields=` names top-level fields: `id`, `name`, `tags`, `position`, `rotation`,
  `scale`, `parent`, `static`, `velocity`, `components`, `overrides`, `script`, `anim`, `vehicle`, `sprite`, `frame`, and
  `transform` for the three transform fields. `id` is always kept. An unknown field is a `400`
  listing every one.
- **A page at a time.** `limit=` cuts the answer; `x-argus-total` says how many matched and
  `x-argus-next-cursor` is the `cursor=` for the next page, absent on the last. A cursor is a
  position in entity order, so an entity spawned or despawned between pages can shift a page by
  one; pin the pages to one state with `?at=` when that matters.

The body keeps its shape: a narrowed answer is still a bare array of entities, each missing only
the fields it was not asked for. `GET /feel` takes the same words ([below](#get-feel)).

`GET /entities?id=crab*&fields=transform,anim&limit=2`

```json
[
  { "id": "crab-1", "position": [4.0, 0.0, -2.0], "rotation": [0.0, 0.0, 0.0, 1.0], "scale": [1.0, 1.0, 1.0],
    "anim": { "state": "walk", "clip": "walk", "time": 0.4, "speed": 1.0, "looping": true, "params": {}, "phase": 0.3 } },
  { "id": "crab-2", "position": [6.5, 0.0, -1.0], "rotation": [0.0, 0.0, 0.0, 1.0], "scale": [1.0, 1.0, 1.0] }
]
```

with `x-argus-total: 7` and `x-argus-next-cursor: 2`.

Component `type` values come from the scene contract: `mesh`, `collider`, `rigidbody`, `light`,
`camera`, `script`, `spawn`, `goal`, `trigger`, `navarea`, `navagent`, `animator`, `audio`,
`particles`, `label`, `hud`, `controller`.

`GET /entities?tag=moving&limit=1`

```json schema=EntitySnapshot[]
[
  {
    "id": "e0",
    "name": "entity-0",
    "tags": ["enemy", "moving", "player"],
    "position": [-7.166, 1.9591131, -7.3306265],
    "rotation": [0.0, 0.0, 0.0, 1.0],
    "scale": [1.0, 1.0, 1.0],
    "static": false,
    "velocity": {
      "linear": [-2.8848248, -0.821622, 2.857725],
      "angular": [0.0, 0.004341185, 0.0]
    },
    "components": [
      { "type": "mesh", "props": { "asset": "cube", "material": "grey" } },
      { "type": "controller", "props": { "speed": 5.0 } }
    ],
    "overrides": {
      "light": { "on": false }
    },
    "script": {
      "module": "behaviors/patrol.ts",
      "state": { "waypoint": 0, "alerted": false },
      "enabled": true
    },
    "anim": {
      "state": "walk",
      "clip": "walk-loop",
      "time": 0.42,
      "speed": 1.0,
      "looping": true,
      "phase": 0.42,
      "params": { "speed": 1.6 },
      "weights": [
        { "clip": "walk-loop", "weight": 0.75 },
        { "clip": "idle-loop", "weight": 0.25 }
      ]
    },
    "frame": 130
  }
]
```

`parent` (a string id) is present when the entity has one. `velocity` is present only for entities
with a rigid body, `script` only with a `script` component, `anim` only with an `animator`, and
`overrides` only when a behavior has written a component on the entity, `vehicle` only on a
running vehicle, and `sprite` only with a `sprite` component.

`sprite` is what the entity's sprite shows (`ctx.sprite`): `animation` (absent while a still frame
shows), `frame`, `frameIndex` (its entry in the animation), `playing`, `speed`, `view` (the view the
last drawn frame showed: 0 the front, counter-clockwise seen from above) and `views` (how many the
frame has), `visible`, `opacity`, and `bounds`, the world box `[min, max]` of the picture showing,
standing on its pivot: for a sprite that turns to the camera, the box it sweeps as it turns, so it
does not depend on where the camera is. Like `script` and `anim` it is published once something
reads entities. `?fields=sprite` keeps just it. See `docs/API.md`, "Sprites".

`vehicle` is what a tester driving the car through `POST /input` steers by: `input` (who owns the
movement: `auto`, the engine from the actions; `script`, a behaviour), `actions` (each role's
`{ action, kind }` where the scene's `gameplay` context binds it: `throttle`, `brake`, `steer` +1
right, `handbrake`), `speed` (m/s along `forward`, negative reversing), `forward` (its nose, the
entity's -Z), `steerAngle` (the wheels' angle now, radians, positive right like the action),
`maxSteer`, `wheelbase`, `minTurnRadius` (at its speed now), `brakeDecel` (m/s² at full brake),
`topSpeed`, the `throttle` and `brake` in force, `gear` (1 or -1) and `wheelsOnGround`. A vehicle with a `drift` block also has
`drift`: `active`, `angle` (radians, positive right), `charge` (seconds), `tier` and `boostTime`. `?fields=vehicle` keeps just it. See `docs/VEHICLES.md` §7.

`anim` is what `ctx.animator` sees: `state` is `ctx.animator.state`, `clip` the clip playing (the
heaviest one on a blend tree), `time` its playhead in seconds. `params` holds every declared
parameter (absent when there are none), after any `damp` has eased it. `phase` is where the playing
state is in its cycle, 0..1 of its length (a blend tree's blended length, which every clip of the
tree shares). `weights` is every clip in the pose with its share, summing to 1: a blend tree's
points at their weights, heaviest first, then — during a crossfade — the clips being blended out
of, each scaled by its state's share; a clip in both is listed once. `once` appears while a one-shot plays, as
`{ "name", "next", "remaining" }`. `script` and `anim` are filled from the first entity read on,
so a server nobody queries never pays for them.

A full unfiltered dump of a 10,000-entity scene is about 3.4 MB. Filter when you can.

### `components` and `overrides`: the authored truth and the runtime one

`components` is the scene **as authored**, contract-typed: every field in it is a field the scene
schema has, and nothing at run time changes it. `overrides` is what a behavior has written with
`entity.set`, keyed by component type, and holds **only the fields that were written**:

```json
{
  "overrides": {
    "light": { "on": false },
    "material": { "baseColor": [1.0, 0.2, 0.1, 1.0] }
  }
}
```

They are two fields rather than one merged view because two of the three writable component types
have fields the contract does not have — `light.on` and `mesh.visible` exist only at run time —
and `material` is not a scene component at all: the contract's `material` is an *asset kind* and
`mesh.material` names a file. Merging would make this endpoint report a `mesh` component carrying
a `visible` field, which no scene could author and a reader validating against the contract would
be right to reject.

So: read `components` for what the level says, `overrides` for what the game has done to it since.
A light bar that is flashing has an unchanged `light` component and an `overrides.light.on` that
alternates. The key is omitted entirely for an entity nothing has written to, which is almost all
of them, and `{}` is never emitted — an absent `overrides` and an empty one would mean the same
thing, so only one of them is written.

The same map rides on every entity in [`GET /snapshot`](#get-snapshot) and is accepted back by
`POST /snapshot`, so a restored checkpoint puts the light bar back as it was rather than as the
scene authored it. An entity in a posted snapshot with no `overrides` key has **its overrides
cleared**, not left alone: "nothing is written here" is a state a checkpoint has to be able to
restore.

### `script`, and why the first read is the slow one

`script` is the same `ctx.state` [`GET /snapshot`](#get-snapshot) carries, plus `enabled`: it is
the behavior's own working memory — which waypoint it is heading for, whether it has seen the
player, what phase of a run it thinks it is in. Reading it is how you debug a behavior that is
running and doing the wrong thing, as against one that is not running at all.

`enabled` is `false` once the behavior has thrown or blown its wall-clock budget and the engine
has stopped calling it. Its `state` is still reported, frozen at the tick it died on, which is
usually the thing you want to look at. An entity whose behavior is disabled therefore still has
a `script` object; it is the *entity with no `script` component* that has no `script` field.

Filling `script` costs a walk of every behavior and a serialisation of each one's state out of
the JS runtime, so the engine does not do it until something asks. Nothing asks until the first
`GET /entities` or `GET /entity/{id}`; that request asks the simulation to publish again before
it answers, so it costs up to one tick and **returns the current state, not an empty one**. If
nothing is draining the command queue at all it waits out the command deadline instead, once, and
then answers from the snapshot without `script` rather than failing. Every read after the first is
served from the published snapshot at no extra cost.

The `404` guard is not affected: an id the snapshot does not have is refused straight out of the
snapshot, so a typo on `POST /capture` or `POST /input` still comes back immediately. The same
deferral covers `GET /contacts` — see [`GET /state`](#get-state).

## `GET /entity/{id}`

One entity, by its stable scene id. `404` when there is no such id.

`GET /entity/e11`

```json schema=EntitySnapshot
{
  "id": "e11",
  "name": "entity-11",
  "tags": ["prop"],
  "position": [0.9861875, 0.743595, -5.1129594],
  "rotation": [0.0, 0.0, 0.0, 1.0],
  "scale": [1.0, 1.0, 1.0],
  "static": true,
  "components": [
    { "type": "mesh", "props": { "asset": "cube", "material": "grey" } },
    { "type": "goal", "props": { "index": 11, "shape": "box" } }
  ],
  "script": {
    "module": "behaviors/patrol.ts",
    "state": { "waypoint": 3, "alerted": false },
    "enabled": true
  },
  "frame": 130
}
```

## `GET /events`

Frame-stamped events, oldest first. The last 1024 are retained.

| Parameter | Shape | Meaning |
|---|---|---|
| `since` | integer | only events with `frame >= since`. Default 0. |
| `after_seq` | integer | only events with `seq > after_seq`. The precise polling cursor. |
| `filter` | string | match `type` or the custom/trigger event name. Comma-separated for alternatives: `filter=trigger,door-opened`. |
| `entity` | string | only events naming this entity id |
| `limit` | integer | at most this many, keeping the most recent |

**Use `after_seq`, not `since`, to poll.** `seq` increases by one per event and never repeats, so
`after_seq` gives you exactly what you missed even when several events share a frame. `since` is
for "what happened around frame N", where a duplicate does not matter.

```json schema=EventRecord[]
[
  {
    "seq": 2,
    "frame": 130,
    "time": 2.1666666666666665,
    "type": "trigger",
    "volume": "door-volume",
    "other": "player",
    "event": "door-opened",
    "entering": true
  },
  {
    "seq": 3,
    "frame": 130,
    "time": 2.1666666666666665,
    "type": "collision",
    "a": "player",
    "b": "floor-tile-7",
    "point": [2.5, 0.0, -1.0],
    "normal": [0.0, 1.0, 0.0],
    "impulse": 12.5,
    "entering": true
  },
  {
    "seq": 4,
    "frame": 130,
    "time": 2.1666666666666665,
    "type": "custom",
    "name": "score-changed",
    "data": { "score": 30 },
    "by": "scorekeeper"
  },
  {
    "seq": 5,
    "frame": 130,
    "time": 2.1666666666666665,
    "type": "script_error",
    "entity": "e3",
    "module": "behaviors/patrol.ts",
    "phase": "update",
    "message": "cannot read property 'x' of undefined"
  },
  {
    "seq": 6,
    "frame": 130,
    "time": 2.1666666666666665,
    "type": "log",
    "entity": "e3",
    "message": "waypoint reached",
    "data": { "index": 2 }
  }
]
```

`type` is one of `collision`, `trigger`, `custom`, `script_error`, `script_warning`, `log`, `ui`. The payload is flattened
into the record, so there is no nested object to unwrap.

A `ui` event's `what` is `pushed`, `popped`, `replaced`, `focus`, `activated`, `emitted`, `submitted`, `scrolled`,
`context`, `diagnostic` or `refused`. A `popped` screen the player's back input dismissed (Escape, the pad's B) carries
`message: "uiBack"`; one the game popped carries none.

`collision.entering` and `trigger.entering` are the same field: true when the pair starts touching
or the volume is entered, false when it stops or is left. Read both the same way. A collision with
`entering: false` reports the pair separating and carries `impulse: 0`, because there is no contact
left to have applied one. Without the flag a reader cannot tell two bodies still resting on each
other from two that have just come apart.

`script_error.phase` is one of `init`, `update`, `dispose`, `onCollision`, `onTrigger`, `onEvent`,
`listener`, `budget`. A `budget` error also carries `ms`, the cost of the offending tick. A
behavior is disabled after it errors, which shows up as `script.enabled: false` on the entity.

`script_warning` carries `entity`, `module`, `code` and `message`, and disables nothing. Its one
code today is `null-handle`: a behavior read `ctx.nav`, `ctx.input`, `ctx.controller` or
`ctx.animator` on an entity without that component (once per behavior and handle).

## `GET /contacts`

Contact points from the most recent physics step.

```json schema=ContactRecord[]
[
  {
    "a": "e0",
    "b": "e1",
    "point": [-6.070686, 1.114969, -7.263609],
    "normal": [0.0, 1.0, 0.0],
    "depth": 0.01,
    "impulse": 4.5,
    "frame": 130
  }
]
```

`normal` points from `a` towards `b`. `depth` is penetration in metres, positive when the shapes
overlap. `impulse` is the normal impulse the solver applied, in newton-seconds.

## `GET /perf`


Cost of the most recent frame. Every field is always present; a counter the host cannot measure
reports `0`, never `null`.

```json schema=PerfSample
{
  "frame": 130,
  "frame_ms": 4.2,
  "physics_ms": 1.1,
  "script_ms": 0.7,
  "render_ms": 2.0,
  "p95_frame_ms": 5.0,
  "draw_calls": 12,
  "instances": 40,
  "triangles": 480,
  "heap_mb": 48.0,
  "entities": 40,
  "bodies": 10,
  "agents": 0,
  "time_scale": 1.0,
  "paused": false,
  "nonfinite_scrubbed": 0,
  "unwired": {
    "audio_play": 0,
    "particles_emit": 0,
    "hud_set": 0,
    "nav_speed_override": 0,
    "audio_bus": 0,
    "audio_snapshot": 0,
    "audio_music": 0,
    "body_push": 0,
    "joint_ignored": 0,
    "vehicle_ignored": 0
  },
  "ui_ms": 0.21,
  "ui_draw_calls": 7,
  "terrain_load": {
    "prewarm_ms": 0.4,
    "decode_ms": 3.1,
    "cache_read_ms": 6.2,
    "evaluate_ms": 9.8,
    "mesh_ms": 21.5,
    "upload_ms": 30.2,
    "collider_ms": 12.7,
    "total_ms": 85.0,
    "disk_hits": 64,
    "cells_evaluated": 0
  }
}
```

`terrain_load` is where the scene load's terrain time went, by stage, measured once when the
terrain was attached and the same in every sample after: `decode_ms` (the terrain file, and the
layers' textures when drawn), `cache_read_ms` (the project's terrain cache), `evaluate_ms` (what
the cache did not have, conforming to roads, the water data, the collision query), `mesh_ms` (the
render meshes on the CPU), `upload_ms` (layers, cell maps and meshes to the renderer),
`collider_ms` (cell entities, colliders, ground materials) and `prewarm_ms` (the early pipeline
start). The stages are disjoint; `total_ms` is the whole attach. `disk_hits` is `0` on a cold load
and `cells_evaluated` is `0` on a warm one. All-zero with no terrain; `mesh_ms` and `upload_ms` are
`0` headless.

`p95_frame_ms` is over the last 120 frames, so a spike can be told from a regression without
polling at 60 Hz. `nonfinite_scrubbed` counts non-finite floats replaced by `0` on the way out
since process start; non-zero means something produced a NaN.

`ui_ms` is the game UI's share of the frame on the CPU (its tick and the mapping of its draw list
onto render instances) and `ui_draw_calls` its share of `draw_calls`; both are `0` with no UI, and
`ui_draw_calls` is `0` headless. `GET /ui/stats` breaks them down by screen.

### `unwired`

Behavior calls the engine **recorded but could not act on**, cumulative since the scene loaded.
All-zero is the healthy answer and is what a scene with no behaviors reports; a non-zero counter
is a call that named something the scene does not have, and the call did nothing.

| Counter | What it counts |
|---|---|
| `audio_play` | `ctx.audio.play(asset)` for an asset with no registered sound |
| `particles_emit` | `ctx.particles.emit(preset)` outside the eight presets the contract defines |
| `hud_set` | `ctx.hud.set(key, …)` for a field nothing draws. 0 with the stock HUD, which draws every field (`meta.hud`, else stacked top-left); counts every call while a project `hud` screen has replaced the stock one (`docs/UI.md`, "The HUD") |
| `nav_speed_override` | `ctx.nav.goTo(target, speed)` with a speed — the crowd has no per-agent speed setter |
| `audio_bus` | a bus `meta.audio` does not declare (the cue still plays, on the default bus) |
| `audio_snapshot` | `ctx.audio.snapshot(name)` with no such snapshot in `meta.audio.snapshots` |
| `audio_music` | `ctx.audio.music.play(layer)` with no such layer in `meta.audio.music.layers` |
| `body_push` | a `ctx.body` force, torque or impulse on a kinematic or static body, or through a handle whose body has gone (docs/API.md, "Forces, torques and impulses") |
| `joint_ignored` | a `ctx.joint` `setMotor`, `stopMotor` or `setLimits` on a fixed joint (no free axis), or through a handle whose joint has gone (docs/API.md, "Joints") |
| `vehicle_ignored` | a `ctx.vehicle.drive` on a vehicle the engine drives from the input actions (`input: "auto"`), or a `ctx.vehicle` call through a handle whose vehicle has gone (docs/VEHICLES.md) |

This is the assertion a scripted run makes after a scene has been driven:

```sh
curl -s localhost:7700/perf | jq '.unwired | add == 0'
```

None of these is an error and none stops the run — that is exactly why they need a number. A
behavior that misspells a preset name, or writes a HUD field the scene's `meta.hud` has no place
for, otherwise fails in complete silence and the only symptom is something missing from the
picture. The counters are cumulative, so poll once before a section and once after to attribute a
rise to that section rather than to the whole run.

`heap_mb` is the **resident set size of the whole engine process**, not the Rust heap: the JS
runtime, the physics world, the entity store and the graphics driver's allocations included, because
those are what grow when a session leaks and a Rust-allocator figure would miss all four. Sampled
from `/proc/self/statm` on Linux and `GetProcessMemoryInfo` on Windows; a platform with neither
reports `0`, which is the "a counter the host cannot measure reports `0`" rule above. It is the same
number `crates/jobs` reports as a worker's `rss_bytes`, so a soak can compare the two.

## `GET /trace`

The ring buffer of per-frame deltas: 300 frames, keyframe every 60.

| Parameter | Shape | Meaning |
|---|---|---|
| `frames` | integer | how many frames to return, newest last. Default **60**, maximum 300. |
| `before` | integer | only frames strictly older than this, for paging backwards |

A `keyframe` carries every non-static entity; an ordinary frame carries only what moved.

`position` is always present: it is why the entity is in the list. `rotation` and `scale` appear
only when they changed, **and always on a keyframe** — most things that move do not rotate, and
60 frames of 1,000 bodies is 1.8 MB of identical `[0,0,0,1]` otherwise.

Values are absolute world-space, not offsets. To reconstruct a full transform: start at the newest
keyframe at or before the frame you want, which carries every field for every non-static entity,
and apply the deltas forward. Any 60-frame window contains exactly one keyframe, so the default
page is always self-contained.

**One response carries at most 60,000 transform deltas.** The ring holds 300 frames, which at
1,000 moving bodies is 300,000 deltas and about 26 MB of JSON — more than any endpoint answers in
30 ms. A response therefore keeps the newest frames that fit and reports the rest in
`x-argus-trace-truncated`. Page backwards by passing the oldest frame you have as `before`:

```json
{ "first_call": "/trace?frames=60", "then": "/trace?frames=60&before=341", "then_again": "/trace?frames=60&before=281" }
```

`GET /trace?frames=2`

```json schema=FrameDelta[]
[
  {
    "frame": 129,
    "keyframe": false,
    "transforms": [
      { "id": "e0", "position": [-7.166, 1.9542632, -7.3306265] },
      { "id": "e10", "position": [-0.6533455, 1.9972527, -4.857719] }
    ],
    "events": []
  },
  {
    "frame": 120,
    "keyframe": true,
    "transforms": [
      { "id": "e0", "position": [-7.166, 1.9591131, -7.3306265], "rotation": [0.0, 0.0, 0.0, 1.0] },
      { "id": "e10", "position": [-0.6533455, 1.9983487, -4.857719], "rotation": [0.0, 0.7071, 0.0, 0.7071] }
    ],
    "events": []
  }
]
```

`events` on a frame holds the same records `GET /events` serves.

## `POST /raycast`

Cast against the live physics broad phase.

```json schema=RaycastRequest
{ "from": [0, 50, 0], "dir": [0, -1, 0], "max": 100 }
```

`dir` need not be normalized but must not be the zero vector. `max` defaults to 1000 metres.

The names other APIs use are accepted for the three: `origin` for `from`, `direction` for `dir`,
`maxDistance` (or `max_distance`) for `max`, and `excludeTags` for `exclude_tags`, as
`ctx.world.raycast` spells it. So `{ "origin": [0, 50, 0], "direction": [0, -1, 0], "maxDistance":
100 }` is the ray above. Giving both spellings of one field is a `400` ("duplicate field").

| field | default | |
|---|---|---|
| `exclude` | none | an entity id, or a list of them, the ray passes straight through. An id the scene does not have is a `404` |
| `exclude_tags` | none | a tag, or a list; every entity carrying one is passed through too |
| `triggers` | `true` | whether trigger volumes count as hits, as `ctx.world.raycast` has it |

**A ray cast down from above the player hits the player first**: it starts outside the capsule, so
the capsule is the nearest thing under it. To find the ground beneath a character, exclude it:

```json schema=RaycastRequest
{ "from": [0, 10, 0], "dir": [0, -1, 0], "exclude": "player", "triggers": false }
```

Several rays at once go under `rays`, up to 256, and are all cast on the same frame. The answer is
a list with one entry per ray, in order, each a hit or `null`:

```json schema=RaycastBatch
{ "rays": [
  { "from": [0, 10, 0], "dir": [0, -1, 0], "exclude": "player" },
  { "from": [2, 10, 0], "dir": [0, -1, 0], "exclude_tags": ["pickup"] }
] }
```

A body of neither shape is a `400` whose message quotes both.

A hit:

```json schema=HitView
{
  "entity": "floor-tile-7",
  "point": [0.0, 0.5, 0.0],
  "normal": [0.0, 1.0, 0.0],
  "distance": 49.5,
  "frame": 130
}
```

A miss is the whole body, and is **not** an error:

```json
null
```

## `POST /path`

Query the navmesh.

```json schema=PathRequest
{ "from": [0, 0, 0], "to": [4, 0, 4] }
```

The response is the waypoint list when a complete route exists, and `null` otherwise — the SPEC's
`Option<Vec<Vec3>>`:

```json
[[0.0, 0.0, 0.0], [2.0, 0.0, 2.0], [4.0, 0.0, 4.0]]
```

`POST /path?detail=1` returns the full result instead. Use it when you need to tell "no route
exists" from "one end is not on the navmesh" — which is exactly what a stuck-agent diagnosis needs:

```json schema=PathResult
{
  "status": "complete",
  "waypoints": [[0.0, 0.0, 0.0], [2.0, 0.0, 2.0], [4.0, 0.0, 4.0]],
  "length": 5.656854,
  "frame": 130
}
```

`status` is one of `complete`, `partial`, `start_off_mesh`, `goal_off_mesh`, `failed`. `waypoints`
is empty for the last three.

A route that crosses a navigation link — a scene's `navlink` component, or one `meta.nav.autoLinks`
generated — says so in `links`, one entry per crossing, in path order. A crossing runs from
`waypoints[waypoint]` to `waypoints[waypoint + 1]`; `link` indexes `GET /navmesh`'s `links`;
`action` is what a player presses at the start while moving to the end (`jump` for a jump link
unless the component names another; absent for a drop, which is crossed by walking off); `hold` is
how many seconds to hold it down (the component's `hold`, 0.25 when it names none; absent with no
`action`), because a game with variable jump height cuts a tapped jump short; `entity` is the
declaring entity, absent for a generated link. `links` is omitted when the route walks the
whole way, so a scene with no links answers exactly as before:

```json schema=PathResult
{
  "status": "complete",
  "waypoints": [[-6.0, 0.0, 0.0], [2.2, 0.0, 0.0], [4.1, 1.5, 0.0], [5.0, 1.5, 0.0]],
  "length": 12.183,
  "frame": 212,
  "links": [{ "waypoint": 1, "link": 0, "kind": "jump", "action": "jump", "hold": 0.25, "entity": "ledge-link" }]
}
```

## `GET /navmesh`

The built navmesh, as plain data.

Query parameters, both optional:

- `bounds=minX,minY,minZ,maxX,maxY,maxZ` — world metres; only the polygons whose own box touches
  this box are returned. `area`, `bounds` and `links` still describe the whole mesh (`links` is
  never filtered, because `POST /path`'s `links[].link` indexes it).
- `limit=N` — lowers the polygon cap (at most 20,000, the default).

A response is never truncated. A request with no `bounds` on a mesh of more polygons than the cap
is refused `400 bad_request` with a message beginning `nav-bounds-required:`; one whose `bounds`
still hold more than the cap, with `nav-bounds-too-large:`. Both say how many polygons there are.

```json schema=NavMeshView
{
  "polys": [
    {
      "vertices": [
        [-9.316954, 0.0, -9.3306265],
        [7.3998413, 0.0, -9.3306265],
        [7.3998413, 0.0, 5.281846],
        [-9.316954, 0.0, 5.281846]
      ],
      "area": 63,
      "flags": 1,
      "areaM2": 244.27371
    }
  ],
  "area": 244.27371,
  "bounds": [[-9.316954, 0.0, -9.3306265], [7.3998413, 0.0, 5.281846]]
}
```

`polys[].vertices` are counter-clockwise world-space corners. `polys[].area` is the Recast area id,
an integer; `polys[].areaM2` is that polygon's surface in square metres. Top-level `area` is the
total walkable surface. `bounds` is `[min, max]`.

`links` lists the off-mesh links baked into the mesh, with their ends snapped onto it; it is
omitted when there are none. Each has `start`, `end`, `kind` (`jump`, `drop`, `generic`),
`bidirectional`, `auto` (generated by `meta.nav.autoLinks` rather than declared), and, when there is
one, the `action` to press, the seconds to `hold` it and the declaring `entity`. A link's Detour polygon carries area 61
(jump), 62 (drop) or 60 (generic).

```json schema=NavMeshView
{
  "polys": [],
  "area": 0.0,
  "bounds": [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]],
  "links": [
    { "start": [2.2, 0.0, 0.0], "end": [4.1, 1.5, 0.0], "kind": "jump", "bidirectional": false, "action": "jump", "hold": 0.25, "entity": "ledge-link", "auto": false }
  ]
}
```

## `GET /frame`

The current frame index. The one endpoint that returns a JSON scalar rather than an object,
because the SPEC's table says `u64`:

```json schema=FrameIndex
130
```

Its schema is named `FrameIndex` and describes a non-negative integer. The name exists so that
`GET /schema` covers every served endpoint; the body on the wire is the bare number above, not an
object wrapping one.

## `GET /frame.png`

The last rendered frame, as `image/png` bytes. `404` until something has rendered, with a message
naming both ways to get one.

| Parameter | Shape | Meaning |
|---|---|---|
| `fresh` | `1`, `true`, `yes` | render one **now** instead of serving the cached image |

### It is stale, and it says which frame it is

The cached image is refreshed every 30 rendered frames and encoded off the simulation loop, so it
lags the world by up to half a second at 60 Hz — and does not move at all while the simulation is
paused. That is the trade the endpoint exists for: a picture, cheaply, as often as you like.

So the two stamps on the response answer two different questions, and reading the wrong one is how
an agent ends up explaining a collision with a picture taken before it:

| Header | What it means here |
|---|---|
| `x-argus-frame` | the frame **the image shows** — not the frame the server is on |
| `x-argus-stamp` | the committed state the **world** is in right now, as on every other response |

Every other endpoint's `x-argus-frame` is the frame of the body it is attached to, and this is the
same rule; it is only worth spelling out because here the body is a picture and the gap is visible.
`GET /frame` is the live frame in plain form, if you want the two numbers side by side.

### `?fresh=1`

```
GET /frame.png?fresh=1
```

Renders on demand and comes back stamped with the frame it drew. It goes through the same path as
`POST /capture`, so it costs a round trip to the simulation (up to one tick) and holds an
exact-job slot — see [Ceilings](#ceilings). Use it when the picture has to be *this* moment; use
the plain form when roughly current is enough, which is most of the time.

Anything other than `1`, `true` or `yes` means no, and serves the cached image: `?fresh=0`,
`?fresh=` and a typo all take the cheap path rather than a `400`. A picture is not worth an error.

Not in the SPEC's endpoint table: this is the phase-0 endpoint, kept because the CLI and the tracer
gate use it. **`POST /capture` is still the full capture path** — it takes overlays, sizes, an
arbitrary eye point, clips, and can read back the depth and entity-id buffers. `?fresh=1` is the
one-line form of its simplest case.

## `GET /state`

Which committed state this server is serving, and what it will take on. The body form of
`x-argus-stamp`, for a client that reads JSON more easily than headers.

```json schema=StateStampView
{
  "stamp": { "artifact": 1, "frame": 130, "revision": 457, "structure": 3 },
  "contacts_stamp": { "artifact": 1, "frame": 130, "revision": 457, "structure": 3 },
  "readers": 2,
  "max_readers": 32,
  "exact_jobs": 0,
  "max_exact_jobs": 8
}
```

`contacts_stamp` is behind `stamp` when the engine has deferred the contact copy because nothing has
read `GET /contacts` yet. The first read of that endpoint asks the simulation to materialise one and
waits for it, so a first reader sees current state rather than an empty list; after that the two
stamps track each other. `GET /contacts` always reports the state its list actually describes.

## `GET /snapshot`

Everything needed to restore the world to this frame: transforms, velocities, script state,
animator playheads, HUD, goals, and the RNG seed. Entities are in ascending entity-slot order, so
two captures of the same state are byte-identical.

```json schema=WorldStateView
{
  "frame": 130,
  "time": 2.1666666666666665,
  "rng_seed": 24301,
  "entities": [
    {
      "id": "e0",
      "name": "entity-0",
      "tags": ["enemy", "moving", "player"],
      "position": [-7.166, 1.9591131, -7.3306265],
      "rotation": [0.0, 0.0, 0.0, 1.0],
      "scale": [1.0, 1.0, 1.0],
      "velocity": {
        "linear": [-2.8848248, -0.821622, 2.857725],
        "angular": [0.0, 0.004341185, 0.0]
      },
      "script": { "module": "behaviors/patrol.ts", "state": { "waypoint": 0, "alerted": false } },
      "anim": { "state": "idle", "time": 0.0 }
    }
  ],
  "hud": { "score": 0 },
  "goals": [
    { "id": "reach-exit", "labelKey": "goal.reach-exit", "order": 0, "reached": false }
  ],
  "opaque": { "backend": "rapier3d", "version": 1, "data": "" }
}
```

`velocity`, `script` and `anim` are absent when the entity has none — an entity with no rigid body
is not the same as one at rest. `goals[].at_frame` appears once a goal is reached.

`hud` is the exception to [the no-null rule](#conventions): a key whose behavior cleared it with
`ctx.hud.set(key, null)` is published as `"key": null`, not dropped. It has to be. The body is the
input to `POST /snapshot`, and a restore rebuilds the HUD from exactly these keys — so a cleared
field that came back omitted would restore as whatever it held before the clear, which is the one
state the capture was not taken in. A field the scene never set is genuinely absent; a field set to
`null` is a field that is currently showing nothing. Readers that treat "missing" and "null" the
same are safe; readers that want to know which keys the run has touched read the nulls too.

`opaque` is solver state the physics backend cannot rebuild from transforms and velocities alone:
contact islands, sleeping flags, warm-start impulses. It is opaque on purpose and the only thing to
do with it is hand it back. A snapshot taken without it may not restore bit-exactly.

`anim` carries an animator's gameplay state as well as its playhead: `once` (the one-shot playing),
`layers` (`[{ name, state, once }]`), `speed` (the playback rate when it is not 1; 0 is a hit-stop)
and `windows` (the event windows open now, an attack's hit window). Those are for a reader. What a
restore uses is `runtime`, `{ "backend": "argus-animator", "version", "data" }`, the animator's whole
state between ticks (tracks, fades, parameters, layers, the root motion banked for the controller),
so a world restored mid-attack, here or in a fresh world as a late joiner's is, goes on exactly as
the run that never restored. Like `opaque` it is only to be handed back. A snapshot without it (an
older build's), or with another version, restores the animator by `state` and `time` alone, and
the restore's report counts it in `anim_fallbacks` (exact ones in `anim_runtimes`). So does a runtime
from a scene edited since, which names a state, one-shot or clip the animator no longer has. Two things are
not in the runtime: spring bones (hair, cloth) and a face's idle motion start again at rest after a restore,
as they do after a teleport. Neither changes what the game does, only how it looks for a moment.

`spawned` lists the entities a behaviour created at run time (`ctx.world.spawn`) that are alive,
ascending by `id`: `[{ "id": "kart-1", "def": { ...the entity as a scene file holds it... } }]`.
It is absent when nothing was spawned, so a world that never spawned snapshots as it always did, and
a body without it restores as before. A restore recreates an entity it has lost from the scene's
own entry for the id, then from the run's own spawns, then from this `def`; that last one is what
brings back entities spawned before a `loadScene` or in another process. A `def` that is not a
valid entity of its `id` is ignored, and that entity counts in `skipped`.

`road_progress` lists every entity `ctx.world.road(id).progress(entity)` is tracking. It goes road
by road in id order, and each road's entities in the order their tracking started, which is the
standings' last tie-break:
`[{ "road": "circuit", "entity": "kart", "position": [x, y, z], "s": 41.2, "offset": 0.3, "from_line": 0.3, "along": 341.2, "checkpoint": 5, "backward": 0, "reached": 140, "best": 341.2 }]`.
These are the engine's whole track:
- `position`, `s`, `offset` and `from_line`: where the entity was at its last step, in the road's
  own frame.
- `along`: its unwrapped distance past the start line.
- `checkpoint`: the last checkpoint crossed in order, counted across laps.
- `backward`: the metres gone the wrong way.
- `reached` and `best`: the tick and distance of its best.

A world restored from them counts the lap and orders the standings exactly as the world it came
from, here or in a fresh one. The field is absent when nothing is tracked, so such a world
snapshots as it always did. A body without it restores with nothing tracked, and tracking starts
again at the next `progress` call. A track whose road or entity the world does not have is dropped.
So is one with a number no race could make; `POST /snapshot` refuses a non-finite one.

The entity's numeric identity is deliberately not here. `argus_state` tracks an `{index, generation}`
internally because `restore` needs exact object identity across a respawn; nothing but the stable
scene `id` crosses this boundary, as everywhere else in this API.

## `POST /snapshot`

Put the world back into a state a previous `GET /snapshot` returned. The body is exactly that
response, unmodified. This is the playtester's checkpoint restore: capture before a risky attempt,
restore and retry when it fails.

```json schema=WorldStateView
{
  "frame": 130,
  "time": 2.1666666666666665,
  "rng_seed": 24301,
  "entities": [
    {
      "id": "e0",
      "name": "entity-0",
      "tags": ["enemy", "moving", "player"],
      "position": [-7.166, 1.9591131, -7.3306265],
      "rotation": [0.0, 0.0, 0.0, 1.0],
      "scale": [1.0, 1.0, 1.0],
      "velocity": {
        "linear": [-2.8848248, -0.821622, 2.857725],
        "angular": [0.0, 0.004341185, 0.0]
      }
    }
  ],
  "hud": { "score": 0 },
  "goals": [],
  "opaque": { "backend": "rapier3d", "version": 1, "data": "" }
}
```

```json schema=RestoreResponse
{ "restored": true, "frame": 130 }
```

`restored` is always `true` in a `200`; a restore that could not be applied is a `4xx` with the
usual error body. The world is at `frame` afterwards, which is the frame the submitted state
carried.

An entity in the state that the loaded scene no longer has is skipped and counted, so a restore
against a scene edited since the capture is visibly partial rather than silently so:

```json schema=RestoreResponse
{ "restored": true, "frame": 130, "skipped": 3 }
```

`skipped` is absent when every entity was applied. A state in which *no* entity matches is a `400`,
because that is a snapshot from a different scene rather than a partial restore.

A capture taken while `ctx.session.pause()` holds the world carries `session_paused: true`, and
the restore puts the hold back. A held tick counts a `frame` without advancing `time`, so after a
pause `time` is behind `frame` × the timestep; the restore keeps a `time` that is behind, and
recomputes any other from `frame`. A `pausesWorld` screen is not captured: the UI stack is not part
of the state.

Include `opaque` if the capture had it. Without it the restore still places every transform and
velocity, but the physics solver starts from a clean slate, so `restore` then stepping will not
match continuous stepping bit for bit.

**Size and cost.** This is the only request in the API measured in megabytes: a 10,000-entity state
is about 1.8 MB. The route accepts bodies up to 64 MiB, where every other endpoint accepts far
less, and refuses more than 200,000 entities with a reason.

It is also the only endpoint not held to the 30 ms query budget, because it is a write of the whole
world rather than a question about it. It measures about 26 ms on the 10,000-entity fixture and is
budgeted at 100 ms.

**Implementers: index by id before applying.** Matching each submitted entity against the live
table by scanning it is O(n²) — a hundred million string comparisons on 10,000 entities, which
measured 193 ms. Building a map of id to slot first brings the same restore to 26 ms.

## `POST /capture`

Render and read back. The `kind` field picks the variant. The SPEC writes this as
`FrameOpts | ClipOpts | AtOpts`; an untagged union of three structs with overlapping optional
fields cannot be parsed unambiguously or errored on usefully, so the frozen form is tagged.

Shared fields on every kind: `overlays` (default `[]`), `buffers` (default
`{"depth":false,"ids":false}`), `width` and `height` (default: the scene's configured size, max
4096).

`{"kind":"frame"}` — one frame from the scene's active camera.

```json schema=CaptureRequest
{ "kind": "frame", "overlays": [{ "type": "colliders" }], "buffers": { "depth": true, "ids": true } }
```

When a script's third-person or orbit rig is the view, a `frame` capture's `camera` also carries
`arm`: what the spring arm was doing — the length asked for, the length it had, whether a wall
collapsed it (`collapsed`: under 0.5 m, the eye in or against the character) and what wall
(`blocker`, an entity id, absent when nothing blocks), the character `fade` (0..1; faded pixels are
dropped by a dither, so they are missing from the depth and id buffers too), and the `probeRadius`
the sweep used, widened so the near plane never enters geometry. On a `clip`, each frame's own.
Absent on `at` and `views` frames and whenever no arm rig is active. It is `ctx.camera.arm` on the wire.

```json schema=CaptureResponse
{
  "kind": "frame",
  "frames": [
    { "frame_index": 212, "width": 640, "height": 360, "png": "iVBORw0KGgo…",
      "camera": { "eye": [0.4, 1.9, -0.4], "target": [0.7, 1.6, -0.7], "up": [0.0, 1.0, 0.0],
                  "fovY": 1.5707964, "near": 0.1, "far": 500.0,
                  "arm": { "desired": 4.0, "length": 0.12, "collapsed": true, "blocked": true,
                           "blocker": "side-wall", "fade": 1.0, "probeRadius": 0.29 } },
      "perf": { "render_ms": 0.6, "readback_ms": 0.3, "encode_ms": 0.1, "total_ms": 1.0,
                "draw_calls": 12, "instances": 40, "triangles": 480, "overlay_vertices": 0 } }
  ],
  "elapsed_ms": 1.0
}
```

### The UI in an ordinary capture, and `?no_ui=1`

An ordinary `POST /capture` (any kind) draws **the UI a player would see over the world**, laid
out at the size captured, from the screens on the stack at that moment. (Before the k5 fix a
render-on-request engine, which never draws a frame of its own, showed no UI at all until a
`?ui_only=1` capture had left one on the renderer, and then that stale picture of it.)
`?no_ui=1` leaves the UI out: the world alone, for any kind, a clip included.

### `?ui_only=1` and `?node=<id>` — the UI screenshots

Two query parameters, so the body is unchanged and a client that knows `POST /capture` keeps
writing it. Both apply to `{"kind":"frame"}` and `{"kind":"at"}` only; a clip or a `views`
capture with either is `400`.

`?ui_only=1` renders **the UI pass alone** — no meshes, no world labels, no particles, no
overlays and not the scene's sky — over a flat constant background, so two captures of the same
screen at the same size are the same bytes whatever the game is doing behind them. `1`, `true`,
`yes`, `on` and a bare `?ui_only` all mean yes.

`?node=<id>` crops to that node's **painted** rectangle in device pixels: the `bounds.device`
`GET /ui` reports, intersected with `bounds.clip`. Takes the qualified id or the bare one. A
node that is not on screen, is under `display: none`, or is clipped away entirely is `400` with
the reason — never a 0×0 image. The two combine: `?ui_only=1&node=panel:start` is a picture of
one component and nothing else.

The crop is taken from the rendered frame rather than by re-rendering at the node's size.
Re-rendering would lay the screen out into a different viewport and the node would not be the
node any more; and a shadow or a glow that reaches outside the rectangle is part of what the
node looks like, so the crop keeps whatever the frame put there.

Neither returns a depth or an id buffer: a UI-only frame has no entities and no useful depth,
and a buffer whose pixels did not line up with the image beside it would be worse than none.

> **Screenshots are evidence, not assertions**. Pixel-diffing whole
> screens produces snapshot churn and flaky failures. The machine-checkable claims about a
> screen belong in the critic's UI checks, which assert over the computed tree.

`{"kind":"clip"}` — a run of frames. `seconds` in `(0, 60]`, `fps` in `[1, 120]`, default 15.
The first frame is the world as it stands; between frames the world runs `1 / fps` simulated
seconds in whole ticks (at least one), through the same hooks a `POST /time` step takes, so
inputs held with `POST /input` act during the clip and a read afterwards sees the world where the
clip left it. Each frame's `camera.arm` is the arm at that frame.

```json schema=CaptureRequest
{ "kind": "clip", "seconds": 5.0, "fps": 15 }
```

A clip renders every frame on the simulation thread before it answers. The simulation must
*take* any command within the command deadline (2 s); once taken, a command has 60 s
(`COMMAND_WORK_LIMIT`), and a capture has the larger of that and the command deadline **plus 10 s
per frame** requested (`CAPTURE_FRAME_ALLOWANCE`; a `views` capture counts 4, a `frame`, `at` or `material` 1).
An HD 1080p clip at ~0.3 s a frame used to answer `503 timeout` at 2 s while the engine was still
drawing it. The simulation answers nothing else while a
clip renders, and every frame comes back base64 in one JSON body, so for anything long, large or
slow — a trailer, a replay, HD on a software adapter — use **`argus run <project> --record <dir>`**
instead (docs/API.md, "Recording frames offline"): no HTTP, no deadline, one PNG per frame on disk.
One thing differs: a clip's frames are drawn through `argus_capture`, which does not re-upload
the UI, labels, particles or skinning between the ticks it steps, where `--record` draws each frame
the way the game does.

`{"kind":"at"}` — one frame from an arbitrary eye point. `pos` and `look` must differ.

```json schema=CaptureRequest
{ "kind": "at", "pos": [0, 10, 0], "look": [0, 0, 0], "overlays": [{ "type": "findings", "positions": [[1, 2, 3]] }] }
```

`{"kind":"views"}` — the four canonical views of one entity: front, side, top, iso. This is the
asset stage's four-view vision check. `entity` is the stable scene id, not a number.

```json schema=CaptureRequest
{ "kind": "views", "entity": "player" }
```

It takes the same `overlays`, `width` and `height` as every other kind:

```json schema=CaptureRequest
{ "kind": "views", "entity": "player", "width": 320, "height": 240, "overlays": [{ "type": "bounds" }] }
```

It always answers with JSON, never raw PNG, because there are four images. Each is the same frame
shape the other kinds return, with a `view` field naming which one it is, always in the order
front, side, top, iso:

```json schema=CaptureResponse
{
  "kind": "views",
  "frames": [
    { "view": "front", "frame_index": 131, "width": 320, "height": 240, "png": "iVBORw0KGgo…",
      "camera": { "eye": [0.0, 1.0, 6.0], "target": [0.0, 1.0, 0.0], "up": [0.0, 1.0, 0.0],
                  "fovY": 1.0471976, "near": 0.1, "far": 500.0 },
      "perf": { "render_ms": 0.6, "readback_ms": 0.3, "encode_ms": 0.1, "total_ms": 1.0,
                "draw_calls": 12, "instances": 40, "triangles": 480, "overlay_vertices": 0 } },
    { "view": "side", "frame_index": 131, "width": 320, "height": 240, "png": "iVBORw0KGgo…",
      "camera": { "eye": [6.0, 1.0, 0.0], "target": [0.0, 1.0, 0.0], "up": [0.0, 1.0, 0.0],
                  "fovY": 1.0471976, "near": 0.1, "far": 500.0 },
      "perf": { "render_ms": 0.6, "readback_ms": 0.3, "encode_ms": 0.1, "total_ms": 1.0,
                "draw_calls": 12, "instances": 40, "triangles": 480, "overlay_vertices": 0 } },
    { "view": "top", "frame_index": 131, "width": 320, "height": 240, "png": "iVBORw0KGgo…",
      "camera": { "eye": [0.0, 7.0, 0.0], "target": [0.0, 1.0, 0.0], "up": [0.0, 0.0, -1.0],
                  "fovY": 1.0471976, "near": 0.1, "far": 500.0 },
      "perf": { "render_ms": 0.6, "readback_ms": 0.3, "encode_ms": 0.1, "total_ms": 1.0,
                "draw_calls": 12, "instances": 40, "triangles": 480, "overlay_vertices": 0 } },
    { "view": "iso", "frame_index": 131, "width": 320, "height": 240, "png": "iVBORw0KGgo…",
      "camera": { "eye": [4.0, 4.0, 4.0], "target": [0.0, 1.0, 0.0], "up": [0.0, 1.0, 0.0],
                  "fovY": 1.0471976, "near": 0.1, "far": 500.0 },
      "perf": { "render_ms": 0.6, "readback_ms": 0.3, "encode_ms": 0.1, "total_ms": 1.0,
                "draw_calls": 12, "instances": 40, "triangles": 480, "overlay_vertices": 0 } }
  ],
  "elapsed_ms": 4.0
}
```

### Overlays

Wireframe passes drawn over the colour buffer, each tagged by `type`: `colliders`, `navmesh`,
`triggers`, `rays`, `bounds`, and two that carry data:

```json
[
  { "type": "colliders" },
  { "type": "navmesh" },
  { "type": "findings", "positions": [[1, 2, 3], [4, 5, 6]] },
  { "type": "paths", "paths": [[[0, 0, 0], [1, 0, 1], [2, 0, 2]]] }
]
```

`{"kind":"material"}` — one material on a preview shape, drawn alone at the origin under the
scene's sky, sun, fog and `meta.render` (point lights and the scene's own entities are left out),
from a fixed camera a little above the shape. `material` is a scene asset id of kind `material`, or
a material file's JSON as an object (docs/MATERIALS.md; texture references are texture asset ids or
paths from the scene's folder). `shape` is `sphere` (the default), `cube` or `plane`; the size
defaults to 256x256. It takes no overlays or buffers. It answers like a `frame`: raw PNG, or JSON
with `?format=json`.

```json schema=CaptureRequest
{ "kind": "material", "material": "brick", "shape": "cube" }
```

```json schema=CaptureRequest
{ "kind": "material", "material": { "shading": "vertex", "filter": "nearest", "baseColor": [0.8, 0.4, 0.2] } }
```

A material asset that is not in the scene or is not a material, or JSON the material contract
refuses, is a `400` whose message says what was wrong (the contract's first violation).

### Response

A `frame` or `at` capture answers with raw `image/png` bytes. A `clip` or `views` capture answers
with JSON. Add `?format=json` to get JSON for any kind — that is how you reach the depth and id
buffers, which are base64 PNGs alongside the colour one.

```json schema=CaptureResponse
{
  "kind": "frame",
  "frames": [
    {
      "frame_index": 131,
      "width": 16,
      "height": 16,
      "png": "iVBORw0KGgoAAAANSUhEUg…",
      "depth": "AAAAAJqZmT0zMzM+…",
      "ids": "AAAAAAEAAAACAAAA…",
      "camera": {
        "eye": [0.0, 8.0, 12.0],
        "target": [0.0, 0.0, 0.0],
        "up": [0.0, 1.0, 0.0],
        "fovY": 1.0471976,
        "near": 0.1,
        "far": 500.0
      },
      "perf": {
        "render_ms": 0.6,
        "readback_ms": 0.3,
        "encode_ms": 0.1,
        "total_ms": 1.0,
        "draw_calls": 12,
        "instances": 40,
        "triangles": 480,
        "overlay_vertices": 0
      }
    }
  ],
  "elapsed_ms": 1.0
}
```

`png` is base64, standard alphabet, padded. A `views` capture adds `view`, one of `front`, `side`,
`top`, `iso`. `elapsed_ms` is measured on the simulation thread, and `perf` is per frame and
capture-specific — `readback_ms` and `encode_ms` exist nowhere else, and they are usually where a
capture that misses its budget loses the time.

`camera` is the camera the frame was taken through, so a caller can project a world position into
the image afterwards. That is what turning a critic finding into "here it is, circled" needs.

### `depth` and `ids` are not images

They appear only when the matching `buffers` flag was set, and they are **base64 of the raw
little-endian pixel arrays**, `width * height` values in row-major order — `f32` for `depth`
(`0` at the near plane, `1` at the far plane), `u32` for `ids`.

Encoding them as PNGs would mean quantizing depth to 16 bits and packing 32-bit entity ids across
RGBA channels, and every client would then need a PNG decoder to read one number back out. Raw is
lossless, smaller than JSON numbers, and one `DataView` away:

```json
{ "pick": "ids[y * width + x], where 0 means no entity covered that pixel" }
```

```js
const buf = Uint8Array.from(atob(frame.ids), c => c.charCodeAt(0));
const ids = new Uint32Array(buf.buffer);
const entityAt = (x, y) => ids[y * frame.width + x] || null;
```

## `POST /time`

Change the clock. Every field is optional; absent means "leave it alone", so `{}` reports the
current state without changing it.

```json schema=TimeRequest
{ "scale": 0.5, "paused": true, "step": 1 }
```

`scale` is in `[0.1, 10.0]`. `step` advances exactly that many fixed ticks whether or not the sim
is paused, at most 3600 (one minute at 60 Hz). It is applied after `scale` and `paused`, so
`{"paused": true, "step": 1}` is the debugger gesture: pause, then advance one tick.

```json schema=TimeResponse
{ "scale": 0.5, "paused": true, "frame": 131, "fixed_dt": 0.016666668, "stepped": 1 }
```

## `POST /input`

Synthetic input for the playtester. Applied in order, taking effect on the next tick.

```json schema=InputRequest
{
  "actions": [
    { "type": "action", "name": "jump", "held": true, "frames": 2 },
    { "type": "axis", "name": "move", "value": [1.0, 0.0], "frames": 10 }
  ]
}
```

`name` comes from the scene's input map. `entity` targets one entity's controller; absent means the
scene's player spawn. `frames` is how many ticks the input stays applied before releasing, in
`[1, 600]`, default 1 — so `"held": true` with no `frames` is a tap. An axis `value` is `[x, y]`,
each component in `[-1, 1]`. At most 64 inputs per request.

The body is a list under `actions`, and every event needs its `"type"` (`action`, `axis`, `steer`, `mouse`, `pointer` or
`key`); this is **not** the MCP `play.input` shape, which takes one event with `frames` or `held`
at the top level. An `action` presses only with `"held": true` (for `frames` ticks); `false`, or no `held`,
releases it. An action that asks for `frames` above 1 without `held` is refused with a `400` rather
than accepted as a release that does nothing, and a body the endpoint cannot read is answered with an
example body.

An `action` or `axis` is **player input**: it reaches `ctx.input` and the controller exactly as a
key or a stick does, so a `move` of `[0, 1]` walks away from the camera for a controller with the
default `inputBasis: "camera"`, and moves nothing on an `input: "script"` controller whose behaviour
ignores it (docs/API.md, "Who drives the controller").

A `steer` is world-frame steering for a tool walking a route. `dir` is a world direction `[x, z]`
(each component in `[-1, 1]`, `[0, -1]` is -Z), `speed` is m/s and defaults to the controller
component's `speed`. It reaches the game as the player's `move` stick in `ctx.input`, turned into the
controller's `inputBasis` frame at the length that asks for that speed, so a behaviour cannot tell it
from a person. An `input: "auto"` controller is also walked along `dir` by the engine; for the ticks
it is held that wins over the stick and over a behaviour's `move`, and it is what an animator's
`inputSpeed` reads. An `input: "script"` controller is walked only by its behaviour, from the stick:
one that ignores the stick or has been disabled stands still, and a steer placed while none of its
entity's behaviours runs comes back in `unbound` (docs/API.md, "`POST /input`: a player's hands, and `steer` for tools"):

```json schema=InputRequest
{
  "actions": [
    { "type": "steer", "dir": [0.0, -1.0], "speed": 3.0, "frames": 30 }
  ]
}
```

```json schema=InputResponse
{ "accepted": 2, "frame": 132 }
```

`unbound` lists names no entity in the scene consumes, so a typo shows up as data rather than as
nothing happening:

```json schema=InputResponse
{ "accepted": 1, "frame": 132, "unbound": ["jmup"] }
```

### A mouse

A `mouse` is a person's mouse: `delta` pixels (screen axes, +y down, each in `[-2000, 2000]`) on each of
`frames` ticks, fed in before the action map exactly where the window's mouse goes. Every action bound
to the mouse's `delta` reads it through its own `sensitivity` (degrees per pixel, 0.1 when absent), so a
probe measures the turn a player gets. It is always the player's (there is one mouse), and it is
`unbound` when no action in the map reads the mouse's `delta`:

```json schema=InputRequest
{
  "actions": [
    { "type": "mouse", "delta": [240.0, 0.0], "frames": 10 }
  ]
}
```

### A pointer

A `pointer` says where the mouse points: pixel `(x, y)` of view `view`'s cell (`view` 1 to 4, default 1;
`x`, `y` in `[-100000, 100000]`), the space `ctx.input.pointer` reports and `ctx.camera.pick(x, y, { view })`
takes. It is a **level**, not a span: it holds until another `pointer` moves it or one with `"off": true` takes it
away, so a caller driving the clock with `POST /time` keeps it across any number of steps. It is the window
cursor's own state (there is one mouse), recorded by a replay, and applies at the next tick. A `view` the layout
does not have comes back in `unbound`.

```json schema=InputRequest
{
  "actions": [
    { "type": "pointer", "view": 1, "x": 640.0, "y": 360.0 }
  ]
}
```

### A key, and an axis held until it is released

A `key` is one keyboard key going down or up, the way a window's keyboard sends it. It is offered to
the screen stack first (a key a menu or a focused field takes is not the game's), then goes into the
action map of the **live input context**, which is what turns it into an action: `W` is `move` while
the play context is live and nothing while a menu is. It stays down until a `down: false` for the
same key, so a caller that drives the clock with `POST /time` can hold a key across as many steps as
it likes. `code` is a scene's input-map spelling (`W`, `Space`, `LShift`, `Return`) or winit's
(`KeyW`, `ArrowUp`, `ShiftLeft`, `Enter`); a name nothing answers to comes back in `unbound`. There
is one keyboard, so a key is always the player's and takes no `entity`.

```json schema=InputRequest
{
  "actions": [
    { "type": "key", "code": "W", "down": true }
  ]
}
```

An `axis` with `"sustain": true` is held until the same axis is placed again or released (a `value`
of `[0, 0]` replaces it), whatever `frames` says. That is how a caller that steps the clock itself
keeps a stick over for longer than the 600-tick ceiling of a `frames` hold:

```json schema=InputRequest
{
  "actions": [
    { "type": "axis", "name": "move", "value": [0.0, 1.0], "sustain": true }
  ]
}
```

## `GET /input`

Where a press goes: the action map of the scene that is **running now** (after a `POST /scene` or a
`ctx.session.loadScene` that is not the scene the caller opened), the live input context, and the
entity player input reaches. A caller builds its action-to-key table from this, and reads it again
after a press to learn whether anything read it. Needs `observe`.

```json schema=InputStateView
{
  "scene": "scenes/main.scene.json",
  "input": { "active": "play", "contexts": { "play": { "move": { "kind": "axis2", "keys": ["W", "A", "S", "D"] } } } },
  "context": "play",
  "frame": 130,
  "receiver": { "entity": "player", "scripts": ["behaviors/walker"], "native": true },
  "unrouted": []
}
```

`input` is absent when the scene has no `meta.input` and the built-in map is the one running.
`receiver` is absent when the scene has no player. `unrouted` lists entities whose behaviours read
input while nothing routes it to them (the runtime's `input-read-without-routing` warning).

## `POST /scene`

Switch the running world to another scene of the project: `ctx.session.loadScene` from outside, with
the same rules. The path is project-relative with `/` separators, resolved inside the session's
content root, and may not leave it. A refusal changes nothing and keeps the runtime's own code in
its message: `404` with `scene-missing`, `400` with `scene-path-refused`, `scene-invalid`,
`scene-no-root` or `scene-load-failed`. Needs `reload`, the capability for changing what the
running world is made of without restarting the process; a paused world stays paused.

```json schema=SceneRequest
{ "scene": "scenes/track-meadow.scene.json" }
```

```json schema=SceneResponse
{ "scene": "scenes/track-meadow.scene.json", "frame": 0, "entities": 1756 }
```

## `POST /step`

Inputs, ticks and a read in one call: `POST /input`, then `POST /time {step}`, then `GET /entity/{id}` for
each id asked, applied in that order. A client that steps a paused world (the playtester does, every few
ticks) used to make those three requests each time; each is a command the simulation answers in turn, so
this is one round trip and one wake of its loop instead of three.

```json schema=StepRequest
{
  "input": [{ "type": "axis", "name": "steer", "value": [1.0, 0.0], "frames": 6 }],
  "step": 6,
  "entities": ["player"]
}
```

`input` is `POST /input`'s events (the same types, the same validation, the same refusal of an `action`
that asks for `frames` with no `held`), at most 64, and may be absent. `step` is whole ticks, at most 3600,
one when absent, run whether or not the world is paused, exactly as `POST /time {step}` runs them.
`entities` are ids read after the ticks, at most 64, answered as `GET /entity/{id}` answers them, from the
state the ticks committed, so each carries the frame the response names. It needs both capabilities,
`time` and `input` (a holder of one cannot do the other's work through it); with a managed session a
caller without either is refused as for `POST /time` or `POST /input`. The route changes neither the
scale nor the pause state; use `POST /time` for those.

```json schema=StepResponse
{
  "frame": 137,
  "paused": true,
  "stepped": 6,
  "input": { "accepted": 1, "frame": 131 },
  "entities": []
}
```

`input` is `POST /input`'s answer for the events placed (`unbound` names included), absent when there were
none. Nothing is placed and no tick runs when a request is refused: an unknown entity to read or to
target is a `404`, a bad event or too many ticks a `400`.

## `POST /entity/{id}/teleport`

Put one entity somewhere else outright: the move a behaviour's `ctx.entity.teleport(position, yaw)`
makes. The transform, the physics body and the controller move together, the controller's velocity
is cleared, and the next frame is drawn where it landed rather than interpolated there. `position`
is the entity's own, local to its parent if it has one; `yaw` (radians about +Y, 0 facing -Z) turns
it and is optional. A `yaw` also turns the camera rig filming the character; with no `yaw` the
facing and the view heading are both left exactly as they were. Needs `snapshot-restore`.

```json schema=TeleportRequest
{ "position": [2.0, 0.0, -7.0], "yaw": 3.14159 }
```

```json schema=TeleportResponse
{ "entity": "player", "position": [2.0, 0.0, -7.0], "frame": 412 }
```

`position` in the response is where it landed: a character put inside geometry is pushed out of it.
It is answered after the move is published, so a read that follows sees it. An entity the scene
does not have is `404`; a position or yaw that is not finite, a position outside `meta.bounds`, or
one inside geometry with no way out inside the bounds is `400` and moves nothing.

**Nothing else is rewound**, which is what makes this different from `POST /snapshot` with one
position changed: the frame, the other entities and every behaviour's own state are left alone. So
the game is told. At the top of the next tick, before the controllers step and before behaviours
run, the engine raises a bus event `teleported` (`by` null) with the entity, where it landed, where
it was and the yaw it was given:

```json schema=EventRecord
{ "seq": 90, "frame": 413, "time": 6.883333, "type": "custom", "name": "teleported", "data": { "entity": "player", "position": [2.0, 0.0, -7.0], "from": [0.0, 0.0, 0.0], "yaw": 3.14159 } }
```

A behaviour that keeps its own velocity or state machine resets it on that event
(`ctx.events.on("teleported", …)` or `onEvent`); `GET /events?filter=teleported` lists it.

Two ticks after a teleport with a `yaw`, the engine checks that the view took it. When a behaviour
wrote the old heading back, it raises the `teleport-heading-ignored` script warning (`GET /events?filter=script_warning`).

Two ticks after any teleport of 0.25 m or more, the engine checks that the move stuck. When a
behaviour wrote the entity's old position back (it keeps the position in its own variables and never
adopts `teleported`), it raises the `teleport-position-ignored` script warning, naming that
behaviour as the `module`, once per entity per run.

## `GET /environment`

The sky as it actually resolved, which is not always what the scene document asked for: a scene
whose panorama is missing reports the default sky it fell back to and says why in `findings`.

```json schema=EnvironmentView
{
  "kind": "procedural",
  "preset": "clear-noon",
  "intensity": 1.0,
  "rotation": 0.0,
  "sun_direction": [-0.35, -0.87, -0.35],
  "sun_color": [1.0, 0.96, 0.9],
  "sun_intensity": 1.0,
  "sun_from_sky": false,
  "persisted": false
}
```

`kind` is `procedural`, `hdr`, `color` or `atmosphere`. `preset` is present only for a procedural
sky (its preset) or an atmosphere sky (`sky.atmosphere.preset`, default `earth-clear`). `asset`
and `asset_path` name the panorama an `hdr` sky loaded. `sun_direction` is the direction the
sunlight travels (sun towards scene) and `sun_intensity` is 0 when the sun is below the horizon,
so `sun_intensity > 0` is the test for "it is daytime". `persisted` is always false: this endpoint
reports the live sky, and a live sky is only on disk when someone edited the scene file.

## `POST /environment`

Swap the sky live. The body is a **partial** sky merged onto whatever is running, so a preset swap
is one field and does not reset the rotation someone spent a minute getting right.

```json schema=EnvironmentRequest
{ "sky": { "preset": "dusk", "rotation": 1.25 } }
```

Every field is optional, `sky` included, so `{}` reports the current environment without changing
it. `kind` is `procedural`, `hdr`, `color` or `atmosphere` (an atmosphere sky's own settings are
written in the scene file); a `preset` with no `kind` means `procedural` and an
`asset` with no `kind` means `hdr`, because that is the only thing either can mean. `asset` must
already be in `scene.assets` as an `envmap`: this endpoint swaps skies, it does not add assets.
`intensity` must not be negative, `turbidity` is in `[1.8, 10]`, and the merged environment then
goes through the same contract rules a scene file does — so a swap can never produce a scene a
file could not.

```json schema=EnvironmentResponse
{
  "kind": "procedural",
  "preset": "dusk",
  "intensity": 1.0,
  "rotation": 1.25,
  "sun_from_sky": false,
  "persisted": false,
  "frame": 132,
  "changed": ["sky"]
}
```

`changed` names what actually moved, so a caller sees when a field it sent was a no-op. **Nothing
is written to disk.** The next hot reload takes the document's sky back, which is what makes
trying ten skies in a second safe. To keep a sky, edit `meta.environment.sky` in the scene file.

`docs/SKY.md` is the field reference and the list of presets.

## `GET /save`, `POST /save` and `GET /roads`

What a caller outside the game needs to reach a state in it, with no test hook in the game's code.

`GET /save` answers `{ "values": {key: json}, "persistent": bool, "path": string|null }`: every key of the
running game's `ctx.save` store (`persistent` is false for the in-memory store a bare scene, a test world
and the editor's Play get). `POST /save { "set": {key: json}, "remove": [key] }` changes it (sets first, then
removes; a key that is not there is fine) and answers the store after. A key is 1 to 64 characters of
`A-Z a-z 0-9 . _ -` and the whole store may be 256 KiB: a refusal (`save-bad-key`, `save-full`) is `400` and
changes nothing of that write's key. The write is flushed at once, because a paused world has no tick to do it.
It needs `snapshot-restore`, as a teleport does: it is a state write outside the game's rules.

`GET /roads?id=&step_m=` answers `{ "roads": [ ... ] }`, one per `road` entity (the `.road.json` file the
scene's `road` component names): `id`, `length`, `closed`, `space: "world"`, `stepM` (the spacing reported),
`samples` (each `{s, pos, tangent, halfWidth, curvature, bankRad}`, position and tangent in WORLD space: the
road entity's transform is applied, unlike `ctx.world.road`, which reads the entity's own frame; `halfWidth` is
half the driving surface's width there), `gates` (`{id, s, position}` in lap order, gate 0 the start line),
`kerbWidthM` and `barrier`. At most 400 samples a road unless `step_m` asks for a spacing; `id` names one road
(an unknown id is `400` listing the scene's roads). Other spline assets (terrain splines, paths) are not served
here: this engine has the road as its one path asset a game reads at run time.

## `GET /npc/log`

`GET /npc/log?id=<entity id>` answers `{ "id": "g1", "entries": [ {tick, action, rule, target}, ... ] }`: the
last decisions of one NPC, oldest first, at most 16. `action` is what it chose, `rule` the name of the rule that chose it
and `target` the entity id (the ECS entity index) it aimed at. `400` without `id` (or with a parameter the route does not
take); `404` (`not_found`) when `id` is not an NPC of the running scene. It needs `observe`, as `/entities` does.

## `GET /camera/project` and `GET /camera/ray`

Read-only (Observe, like `GET /state`), per split-screen view: where a world point lands on screen, and which
world ray a screen pixel is. Both use the matrices the running game draws each view with, the same
per-view frames a script's `ctx.camera.worldToScreen` and `screenToRay` read (so the answer is as of the top of
the tick, which the body stamps), and the same pixel space: the pixels of the view's own **cell**, top-left 0,0,
in the viewport the sim holds (`ctx.camera.viewSize`'s `pixelWidth` x `pixelHeight`, divided among the cells of the
layout `ctx.views` set). Every answer says which cell with `"cell": { "w", "h" }`, in pixels, so a caller never
guesses the size to scale against.

`GET /camera/project?x=&y=&z=[&view=1]` answers `{ "view", "x", "y", "cell", "tick" }`. `GET /camera/project?entity=<id>`
projects that entity's world position at the answered tick instead of `x`, `y`, `z`. A point at or behind the eye has
`"x": null, "y": null`; a point in front of the camera but off screen answers pixels outside the cell (negative, or
beyond `cell.w` / `cell.h`), not null.

`GET /camera/ray?x=&y=[&view=1]` answers `{ "view", "origin": [x, y, z], "dir": [x, y, z], "cell", "tick" }`: the ray
through that pixel of the view's cell, `origin` on the near plane and `dir` a unit vector. A pixel outside the cell
extrapolates. `GET /camera/project` of a point on the ray answers the pixel back (within half a pixel).

A view with no camera answers `200` with `null` fields (`x`, `y`, `origin`, `dir`, `cell`), so a caller polling
during a load does not branch on errors. Refusals are `400` with the usual body whose `error` is a rule code:

| code | when |
|---|---|
| `camera-view-unknown` | `view` is not a number, is 0, or is beyond the layout's live views (the message says how many) |
| `camera-point-invalid` | `x`, `y` or `z` is missing or not a finite number; `entity` names no entity in the scene; or `entity` is combined with `x`, `y` or `z` (the message names the problem) |

## `GET /feel`

The numbers behind "does this game feel right", for a caller that cannot look at the character or
listen to it. It answers for the tick it is served on; the playtester's feel probes read it every
few ticks while they drive a scripted sequence (`packages/critic`, `critic.play` with `feel`).

With no parameters it answers whole, as below. A rig with sixty joints and a dozen clips makes
that tens of kilobytes per animator, so it narrows with the words `GET /entities` uses:

| Parameter | Shape | Meaning |
|---|---|---|
| `section` | `camera,animators` | keep only these top-level sections: `player`, `camera`, `animators`, `audio`, `particles`, `haptics`, `animator_misses`, and `clips` (below); `frame` is always kept |
| `id` (or `entity`) | `hero,crab*` | only the animators, animator misses, emitters and the burst and rumble records of these entities (a record with no entity goes with an unfiltered answer only); patterns as on `/entities` |
| `name` | pattern | of entities whose name matches |
| `tag` | string | of entities carrying this tag |
| `fields` | `states,clips` | keep only these fields of each animator: `position`, `states`, `joints`, `bind_deviation`, `pose`, `feet`, `foot_joints`, `feet_from`, `feet_method`, `feet_confidence`, `feet_reason`, `feet_unknown`, `rig_joints`, `clips`, `loop_seams`, `loop_ranges`, `pops`, or the groups `gait` (`feet`, `foot_joints`, `feet_from`, `feet_unknown`) and `rig` (`joints`, `rig_joints`, `pose`, `bind_deviation`); `entity` is always kept |
| `limit`, `cursor` | integer, the last page's `x-argus-next-cursor` | page the animators; `x-argus-total` counts them |

The entity filters narrow the animators and the misses, never the player or the camera.
`GET /feel?section=animators&id=hero&fields=states,gait` is one character's states and feet.
`section=clips` is not a field of the whole answer: it is one entry per animator (paged and
filtered as the animators are) with only its `entity`, `clips`, `loop_seams` and `loop_ranges`,
the clip facts the critic's `feel/anim-pop` and `feel/anim-loop-seam` findings point at.
`GET /feel?section=clips&id=hero` is one character's clips, whether each is `loopable`, its
`seam_deg`, and the ranges it loops.
An unknown section or field is a `400` listing every one.

```json schema=FeelView
{
  "frame": 240,
  "player": { "id": "hero", "position": [0.0, 0.0, -3.2], "velocity": [0.0, 0.0, -4.0], "grounded": true, "controller": true, "blocked": false },
  "camera": {
    "kind": "third-person", "source": "rig", "position": [0.0, 2.35, 0.8], "forward": [0.0, -0.17, -0.985], "yaw": 0.0,
    "fov_y": 60.0, "near": 0.1, "near_radius": 0.2, "near_clear": true, "clearance": 1.0, "pitch": -0.17, "follow": "hero",
    "to_player": 4.0, "player_visible": true,
    "arm": { "desired": 4.0, "length": 4.0, "collapsed": false, "blocked": false, "fade": 0.0, "probeRadius": 0.2 }
  },
  "animators": [
    {
      "entity": "hero", "position": [0.0, 0.0, -3.2], "states": ["idle", "jump", "walk"],
      "joints": 3, "bind_deviation": 0.12,
      "pose": [[0.0, 1.0, 0.0], [0.1, 0.5, 0.05], [0.1, 0.05, 0.1]],
      "feet": [[0.1, 0.05, -3.1], [-0.1, 0.12, -3.5]], "foot_joints": ["LeftToeBase", "RightToeBase"],
      "feet_from": "auto", "feet_method": "names", "feet_confidence": 0.95,
      "feet_reason": "named foot, toe or ankle: LeftToeBase, RightToeBase; clip \"Walk\" plants them too",
      "feet_unknown": [],
      "rig_joints": ["Hips", "LeftToeBase", "RightToeBase"],
      "clips": [
        { "name": "Walk", "duration": 1.0, "loopable": true, "seam_deg": 0.0, "max_step_deg": 3.4,
          "ground_speed": 1.5, "speed_from": "feet", "in_place": true, "root_travel": 0.0 },
        { "name": "LookLeft", "duration": 0.97, "loopable": false, "seam_deg": 49.1, "seam_joint": "Head",
          "max_step_deg": 4.4, "ground_speed": 0.0, "speed_from": "none", "in_place": false, "root_travel": 0.0 }
      ],
      "strides": [{ "state": "walk", "clip": "Walk", "at": 1.5, "measured": 1.5, "rate": 1.0 }],
      "loop_seams": [{ "state": "idle", "clip": "LookLeft", "next": "LookLeft", "seam_deg": 49.1, "joint": "Head" }],
      "pops": {
        "count": 3,
        "worst": { "deg": 49.1, "joint": "Head", "clip": "LookLeft", "cause": "wrap", "ticks_ago": 12 },
        "recent": [{ "deg": 49.1, "joint": "Head", "clip": "LookLeft", "cause": "wrap", "ticks_ago": 12 }]
      }
    }
  ],
  "audio": {
    "started": 2, "music": 0, "unplayable": 0, "recent": ["sfx-jump", "sfx-land"],
    "output": "silent", "playing": 1, "peak_voices": 2,
    "voices": [{ "voice": 2, "asset": "sfx-land", "bus": "sfx", "gain": 0.8, "looped": false, "spatial": false, "distance": 0.0, "pitch": 1.0, "seconds": 0.05 }],
    "sounds": [
      { "asset": "sfx-jump", "plays": 1, "peak_gain": 0.8, "max_overlap": 1, "max_stacked_loops": 0, "rms": 0.21 },
      { "asset": "sfx-land", "plays": 1, "peak_gain": 0.8, "max_overlap": 1, "max_stacked_loops": 0, "rms": 0.18 }
    ],
    "stacked": [],
    "level": { "rms": 0.14, "peak": 0.62, "max_rms": 0.2, "max_peak": 0.7 },
    "buses": [
      { "name": "master", "volume": 1.0, "muted": false },
      { "name": "music", "volume": 0.6, "muted": false },
      { "name": "sfx", "volume": 0.8, "muted": false }
    ]
  },
  "particles": {
    "bursts": 3, "burst_particles": 60,
    "recent": [{ "seq": 3, "frame": 238, "entity": "hero", "preset": "sparks", "count": 24, "position": [0.0, 1.0, -3.2] }],
    "emitters": [{ "entity": "torch", "preset": "fire", "emitted": 410, "live": 38 }]
  },
  "haptics": {
    "total": 1, "played": 0,
    "recent": [{ "seq": 1, "frame": 238, "source": "hero", "strength": 0.6, "high": 0.3, "seconds": 0.2 }]
  },
  "animator_misses": [{ "entity": "hero", "call": "set", "name": "Run", "count": 12, "states": ["idle", "jump", "walk"] }]
}
```

- `particles` counts what the world emitted, free when unread (a counter per emitter, a ring of 64 records).
  Each `ctx.particles.emit` is a burst with a session `seq` from 1: keep the last `seq` seen and take the
  `recent` entries after it; a gap before the oldest means the ring overflowed. `entity` is the emitting
  behaviour's entity, `count` capped at the preset's pool. Component emitters are listed in `emitters`
  with a monotonic `emitted` and `live`; diff two reads.
- `haptics` is every `ctx.input.rumble` call, clamped, whether or not a pad took it (`played` counts
  those, 0 headless); `total` is the newest `seq`; `recent` reads as `particles.recent` does.
- `camera.pitch` is the vertical angle of `forward`, radians, positive looking up, the same pitch
  `ctx.camera.set` takes. `look_at` is the world point a fixed rig looks at, when one was set. `target` is the entity a
  lock-on rig is framing; absent while no rig sets one.

- `player` is the entity player input drives (the scene's `spawn { for: "player" }`, else the first
  character controller); `grounded` and `blocked` (its last move was stopped by something) are the
  controller's. `velocity` is the controller's, or for a player without one (a vehicle) its rigid
  body's linear velocity; `[0, 0, 0]` when it has neither.
- `camera.kind` is `scene` (the scene's camera entity), `fixed`, `third-person`, `orbit` or
  `first-person` (a rig a script drives), or `override` (the editor's view). `yaw` is the heading of
  `forward` about +Y, the same yaw `ctx.camera.set` takes: 0 looks down −Z. `source` says who
  places the view: `rig` (the engine's rig, configured by a behaviour through `ctx.camera` — the
  game's own or a built-in such as `player`, whichever called it last), `entity` (the scene's
  primary camera entity, named in `entity`, wherever its transform is: a camera component's `kind`,
  `target` and `distance` do not move it, a rig or a behaviour does), or `editor` (the override).
  Nothing here, and nothing in the feel probes, positions the camera: this is the view the frame is
  drawn from, the same one `POST /capture {"kind":"frame"}` renders.
- `near_clear` is false when a sphere of `near_radius` around the eye — big enough to hold the whole
  near plane at the widest aspect the engine draws — overlaps a solid collider: part of the frame
  is the inside of `near_hit`. Triggers and the followed entity do not count. `clearance` is how far the
  nearest solid is from the eye, measured up to 1 m.
- `player_visible` casts a ray from the eye to the player's chest; `occluder` is what it hit first
  when it is false. `to_player` is that distance.
- An animator's playing state, clip, playhead and parameters are the entity's `anim` on
  `GET /entities`; this reports what that does not. `bind_deviation` is how far the pose carries any joint from its bind position, in metres, so near zero is
  a T-pose however the state reads (it measures a point 10 cm from each joint as well as the
  joint, so a clip that only turns joints counts). `pose` is up to 32 such points in the rig's own
  frame, the same joints on every read: diff two reads to see whether the pose moved. `feet` are,
  world space and at most four, named in `foot_joints`: the joints the animator's `feet` names
  (`feet_from: "animator"`), or else the ones the engine found when it built the animator
  (`feet_from: "auto"`). `feet_method` says how: `names` for joints with a word starting foot, toe
  or ankle (not IK targets or controls); `motion` for the leaf joints a looping clip puts on the
  ground and lifts off it in turn; `bind` for the lowest end of each limb near the ground at bind
  (within a tenth of the skeleton's height, 3 cm at least); `animator`; or `none`. Each rule keeps
  the lowest end of each limb, such as toe ends on a humanoid. `feet_confidence` (0..1) is how sure
  the engine is, and `feet_reason` gives the evidence in one sentence. Below 0.5, name the feet in
  `animator.feet`. `feet` is empty when fewer than two qualify. `feet_unknown` lists names in
  `animator.feet` the rig does not have; with fewer than two found, the engine finds the feet.
  One foot is planted at any moment of a walk, so the slowest foot's horizontal travel between
  ticks while the body moves is foot sliding.
- `rig_joints` names every joint of the rig, in the rig's joint order (parents first): the names
  `animator.feet`, an IK chain, a look-at and a retarget map are written with.
- `clips` is what each clip the animator can play says about itself, measured once when the
  animator is built: `duration` in seconds; `loopable`, false when its last frame is far from its
  first (`seam_deg`, the largest joint turn from the last frame to the first, on `seam_joint`, more
  than 2° and more than three times `max_step_deg`, the largest turn between two neighbouring
  frames), so looped it jumps at the wrap; and `ground_speed`, the speed in m/s at playback rate 1
  that the character must move at for its feet not to slide — how fast the planted foot moves back
  under the body along the way the clip goes (`speed_from: "feet"`), or failing two feet the root's
  travel (`"root"`), or 0 in place (`"none"`), in the world at the entity's scale. `root_travel` is
  the root's planar travel over one cycle, which `rootMotion` moves the character by each loop.
  `in_place` is true for a clip that walks or runs in place: its feet cycle while its root does not
  travel, the usual way an imported rig authors a gait. Only the stance counts — the samples with a
  foot on the floor — so a run's flight phase does not pull its pace down.
  A blend point's `at` belongs near its clip's `ground_speed`, and `strideMatch` plays against it.
- `strides` lists every point of a stride-matched blend tree: `state`, `clip`, `at`, `measured`
  (its clip's `ground_speed`, over its loop range when it has one), `ref_speed` (the point's
  `refSpeed`, which replaces `measured`; absent when the scene gives none) and `rate`, the playback
  rate at its own `at` (`at` over the speed used, 1 in place). A `rate` over 3 or under a third is
  said once on stderr as `animator-stride-rate`: the point sits at a speed its clip was not
  authored for, or its stride was misread and wants a `refSpeed`. Absent when there are none.
- `loop_seams` lists each looping state whose clip does not meet itself (`next` is the same clip),
  or — in a state that plays `clips` — each hand-over from one clip to the next that jumps; the
  engine says each once on stderr as `animator-loop-seam`. For a clip with a loop range
  (`loopStart`/`loopEnd`) the seam is the range's, from its end back to its start, and
  `loop_range` gives `[loopStart, loopEnd]`.
- `loop_ranges` lists every clip a state or a blend point loops part of: `state`, `point` (the
  blend point's index, absent for the state's own clip), `clip`, `loop_start` and `loop_end`, then
  `loopable`, `seam_deg`, `seam_joint`, `max_step_deg` and `ground_speed`, each as for `clips` but
  measured over the range. Absent when there are none.
- `pops` counts the ticks on which a joint turned at least 15° and four times as far as on the
  ticks either side of it: a pose that jumps rather than moves. `cause` is `wrap` (the clip looped
  on that tick), `cut` (a state change with no blend time), `blend` (a blend tree's weights moved by
  more than a quarter in one tick: its parameter stepped) or `other`; `recent` holds the latest
  eight, `ticks_ago` counts back from this read. Absent when there have been none.
- `animator_misses` counts every `ctx.animator.set` or `ctx.animator.play` that named a state or
  clip the animator does not have. `play` throws for one; `set` does not, and says so once per
  entity and name on stderr, so this list is where a misspelt state shows up. A `ctx.animator`
  read that found no animator at all is not here: it is a `script_warning` event with code
  `null-handle` on `GET /events`.
- `camera.arm` is the follow rig's spring arm, exactly as `POST /capture` frames report it
  (`camera.arm` there): present only while a third-person or orbit arm is the view.
- `audio.started` counts voices: `ctx.audio.play` cues and `audio` components that play at load.
- `audio.music` counts the music layers that started, and `unplayable` the `ctx.audio.play` calls
  whose asset had no sound behind it.
- The rest of `audio` is the mixer's own account, and it is the same with or without a device: the
  mixer computes every gain, pan and fade itself and a device only plays what it is handed, so a
  headless run (`ARGUS_NO_AUDIO=1`, every agent's) is heard by reading this. `output` says where the
  mix goes: `device` (`output_note` names it), `silent` (this world never asked for one: headless, a
  worker, a preview, `--no-audio`, `ARGUS_NO_AUDIO`, a window serving the query API without
  `--audio`) or `unavailable` (asked, and the platform refused; `output_note` says why). `voices` is
  every voice sounding now, loudest first: its asset, bus, final `gain` (its volume, every bus, the
  distance rolloff and any fade), whether it loops and is `spatial` (placed with `at`), its
  `distance` from the listener, `pitch` and `seconds` played; `playing` counts them and
  `peak_voices` is the most there ever were. `sounds` lists every asset that has played since load:
  `plays`, the loudest it was mixed at (`peak_gain`), the most of it at once (`max_overlap`), the
  most non-spatial loops of it at once (`max_stacked_loops`), and its own `rms` level measured at
  load (absent for a streamed sound). `stacked` is every asset with three or more identical
  non-spatial loops sounding now, with their summed `gain`: in the centre they add up to one sound
  up to 20·log10(n) dB louder that masks the rest, and the engine says so once as
  `audio-loops-stacked`. `level` estimates the mix from each voice's gain and its sound's level:
  `rms` adds the voices as unrelated signals, `peak` adds their peaks as if they lined up (the worst
  case, so over 1 can clip), each now and as the loudest since load (`max_rms`, `max_peak`). `buses` lists every mixer bus in
  declaration order (`master`, `music`, `sfx` and any `meta.audio` declares) with its own linear
  `volume` (0..1, ramps included, parents not multiplied in) and whether it is `muted`; it is
  absent when the mixer has no buses.
  `GET /feel?section=audio` is all of this on its own; `play.query` reaches it from an agent.

## `GET /profile` and the flight recorder

The engine keeps an always-on ring of the last few seconds of timed spans: each sim phase, each script
behaviour call, the render stages. These endpoints read it.
It lives on the simulation thread, so each answer is made between frames like `GET /feel`. **`GET /perf`
is unchanged.** A source with no recorder answers `{"enabled": false, "why": ...}`, which is also what an
engine started with `ARGUS_PROFILE=off` (or an export built without default features) says.

| Route | Needs | Answers |
|---|---|---|
| `GET /profile` | `observe` | `enabled`, `ring {capacity_spans, spans_written, span_bytes}`, `frames_kept`, `retention_s`, `spikes_seen`, `spikes_suppressed`, `capture_dir`, `auto_capture`, `captures` |
| `GET /profile/frames?last=` | `observe` | `frames`: one row per frame, oldest first (`last` 1-1200, default 120): `frame`, `cost_ms`, `ticks [from, to)`, `spans`, `script_ms`, `physics_ms`, `render_ms`, `top {name, ms}` (the biggest sim phase), `top_behaviour {name, entity_index, ms}` (the biggest behaviour call), `spike`; and `median_ms`, `spike_line_ms`. `spike` is the detector's own line: over 2.5x the median and over it by 2 ms |
| `GET /profile/trace?frames=` or `?capture=<id>` | `observe` | Chrome trace JSON (opens in ui.perfetto.dev) of the live ring or of a saved capture |
| `POST /profile/capture {reason?}` | `capture` | saves the ring now: `id`, `path`, `frames`, `spans`, `writing: true`. The file is written on a worker thread |
| `GET /profile/captures` | `observe` | `dir`, and `captures` newest first: `id`, `bytes`, `modified_unix_s`, `trigger`, `scene` |
| `GET /profile/capture/{id}` | `observe` | one capture's `header`, `frames`, `spans`, `names`, `first_frame`, `last_frame`, `duration_ms`, `trace` |
| `GET /profile/blame?by=&limit=&metric=` | `observe` | who paid: `by` is `script` (per `module.method`: `calls`, `total_ms`, `per_tick_ms`, `mean_us`, `p95_us`, `max_us`, `top_entities`, plus `api_calls` when detail is on), `phase` (per sim phase), `entity` (`metric=script_ms\|triangles\|contacts`), `render` (draw groups), `physics` (`bodies`, `awake_dynamic`, `contacts`, `top_entities`), `asset` (`bytes` per texture and mesh against `resident_bytes`, with what no row names as `unattributed_bytes`) or `load` (each phase of the scene load with its `share`). `limit` 1-200. Every answer says how its numbers were obtained (`measured`, `estimate`, `counted while detail was on`) |
| `POST /profile/detail {on, seconds?}` | `capture` | turns on the counters that cost something in a hot loop: script API calls per behaviour (`world.find`, `world.raycast`, `world.spawn`, `entity.despawn`, `nav.goTo`; each behaviour's total of every engine API call is kept apart as `totals[].total_api_calls`, never as a row beside them) and which entities are in which draw group. Off by default; it turns itself off after `seconds` of sim time (30 by default). Turning it on starts the counts from zero |
| `GET /profile/explain?frames=&capture=&finding=&top=` | `observe` | what the window is bound by and a ranked list of fixes. `bound_by`: `side` (`cpu`, `gpu` or `present` when vsync paces the frame), the dominant `track`, `share_of_frame`, `cpu_ms_p50`, `gpu_ms_p50` (null when the window has no GPU spans: unknown, not zero), `frame_cost_ms_p50` and `p95`. `findings` (top 5, `?top=` up to 20, `?finding=<id>` for one in full): `rank`, `id` (`script-hot`, `script-many`, `physics-heavy`, `draw-bound`, `render-cpu`, `gpu-pass-hot`, `spike`, `periodic`, `unexplained`), `confidence`, `title`, `evidence` (metric, target, value, unit, `method`), `fix`, `saving_ms {low, high, capped_by}` (null when there is no honest number) and `confirm`. A saving obeys `frame cost = max(CPU, GPU)`: a CPU fix on a GPU-bound frame is `0`, with `capped_by` saying why. `not_bottlenecks` says what was ruled out and why. `?capture=<id>` explains a saved capture with the context saved in it |
| `GET /profile/diff?a=&b=&frames=&allow_diverged=` | `observe` | capture `a` against capture `b`, a finished re-run (`b` is its job id: the runs are pooled, the whole-frame path is the sim `tick` because a headless replay has no render, `replay.run_spread_ms` is the spread between runs, and a re-run whose counters diverged from the capture is refused with `400` unless `allow_diverged=true`; findings about render or the GPU are `inconclusive` there), or against the live ring when `b` is absent: `frame_cost_ms {a_p50, b_p50, delta, significant}`, `noise_ms`, `bound_by {a, b}`, `spans` (the significant per-path deltas: a delta is significant only outside the two windows' spread), and `findings`, each of `a`'s findings with `predicted_saving_ms`, `actual_saving_ms` and a `verdict`: `confirmed`, `partial`, `not-confirmed`, `regressed` or `inconclusive`. `replay.available` is `false` when `b` is a capture or the live ring: the second window is whatever ran, not a replay |
| `POST /profile/rerun {a, runs?}` | `input` | replays capture `a`'s recorded run against the project as it is on disk now: starts `runs` (1-5, default 3) child `argus` processes, each `run <scene> --replay <input file> --headless --frames <tick>`, and answers with a `job` id at once. Needs a capture with a replay recipe (`replay.available`), which a run has when it started with `ARGUS_PROFILE_DIR=<folder>` or `--save-replay <file>`; `400` says how to get one otherwise. A scene edited since the recording is refused by the replay itself (`replay-scene-mismatch`, the job `failed`). Needs the `input` capability, which a person's human-play session does not grant an API caller |
| `GET /profile/rerun/{id}` | `observe` | `state` (`running`, `done`, `failed` with `error`), `runs_done`, and when done the `fidelity` check (`ok`, `mismatches`: the entities, bodies and contacts of tick T in the re-run against the capture's) and the `captures` written |

Captures are `.argusprof` files in `ARGUS_PROFILE_DIR`, else `<scene base directory>/.argus/profile`; the newest 8
are kept. A capture `id` is the file stem (`manual-f318`, `absolute-f251`): letters, digits, `-`, `_` and `.`, never
a path (`400`); an id that is not there is `404`. With the recorder off `POST /profile/capture` is `503`, and so is
a capture asked for before any frame has been recorded. Frame numbers are the recorder's own, counting frames it
closed, and are not `x-argus-frame` ticks.

## `WS /stream`

Events as they happen, perf every 60 frames. `ws://127.0.0.1:7701/stream`.

| Parameter | Shape | Meaning |
|---|---|---|
| `filter` | string | same matching as `GET /events?filter=`. Applies to `event` messages only. |
| `entity` | string | only events naming this entity id |
| `frames` | bool | also send a `frame` message every tick. Off by default: 60 messages a second for something `GET /frame` answers on demand. |
| `no_perf` | bool | suppress `perf` messages |

Messages are JSON text, each tagged by `type`. Read `type` first and ignore what you do not
recognize — new variants are an additive change.

The socket opens with exactly one `hello`, before anything else:

```json schema=StreamMessage
{ "type": "hello", "version": "1.0.0", "frame": 130, "last_seq": 6 }
```

`last_seq` is the `seq` of the last event published before you attached. Fetch the backlog with
`GET /events?after_seq=6` and you get every earlier event with no gap and no duplicate, because the
socket picks up strictly after it.

```json schema=StreamMessage
{ "type": "event", "event": { "seq": 7, "frame": 131, "time": 2.183, "type": "trigger", "volume": "door-volume", "other": "player", "event": "door-opened", "entering": true } }
```

```json schema=StreamMessage
{ "type": "perf", "perf": { "frame": 180, "frame_ms": 4.2, "physics_ms": 1.1, "script_ms": 0.7, "render_ms": 2.0, "p95_frame_ms": 5.0, "draw_calls": 12, "instances": 40, "triangles": 480, "heap_mb": 48.0, "entities": 40, "bodies": 10, "agents": 0, "time_scale": 1.0, "paused": false, "nonfinite_scrubbed": 0, "unwired": { "audio_play": 0, "particles_emit": 0, "hud_set": 0, "nav_speed_override": 0, "audio_bus": 0, "audio_snapshot": 0, "audio_music": 0, "body_push": 0, "joint_ignored": 0 } } }
```

```json schema=StreamMessage
{ "type": "frame", "frame": 131 }
```

A client that cannot keep up is never allowed to slow the publisher down. Its oldest messages are
dropped and it is told how many:

```json schema=StreamMessage
{ "type": "lagged", "dropped": 42 }
```

Recover by refetching from the last `seq` you actually received: `GET /events?after_seq=…`.

**Latency.** A trigger firing on frame N reaches the socket by frame N+2. There is no polling
interval and no batching between `publish_event` and the wire.

## `GET /session`

The identity handshake: which process is serving, which session it is, and what a caller may do
there. Open to every caller.

Test workers, the human's Play session and previews serve this API side by side on ports the
operating system chose, so a port is not an identity: a process started later on the same port
answers it just as well. A client that controls a session learns the session id and the serving
instance's nonce from whoever started the process (its ready line, or the workspace service),
checks them here, and sends both on every request afterwards.

From the session's holder, with `x-argus-session: ses_0123456789abcdef0123456789abcdef` and
`x-argus-nonce: 9f86d081884c7d659a2feaa0c55ad015`:

```json schema=SessionHandshake
{
  "managed": true,
  "session_id": "ses_0123456789abcdef0123456789abcdef",
  "kind": "test",
  "project_id": "6f1c2a9e-3b7d-4c1e-9a2f-5d8e7b6c4a31",
  "artifact_id": "art_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "job_id": "job_0123456789abcdef0123456789abcdef",
  "capabilities": ["capture", "input", "observe", "snapshot-read", "snapshot-restore", "time"],
  "anonymous_capabilities": ["observe"],
  "pid": 48213,
  "nonce": "9f86d081884c7d659a2feaa0c55ad015",
  "endpoints": { "http": "http://127.0.0.1:41873", "ws": "ws://127.0.0.1:41874/stream" },
  "devices": { "window": false, "gamepads": false, "audio_output": false },
  "version": "1.0.0"
}
```

| Field | Meaning |
|---|---|
| `managed` | whether this server enforces a session's capabilities |
| `session_id`, `kind` | the workspace contract's `SessionId` and `SessionKind` (`preview`, `human-play`, `test`). Absent on a standalone server. |
| `project_id`, `artifact_id`, `job_id` | what the launcher declared. The engine echoes them and computes none. A `test` session always has `job_id`. |
| `capabilities` | what a request presenting this server's nonce may do, sorted |
| `anonymous_capabilities` | what a request presenting no identity may do, sorted |
| `pid` | the serving process. Diagnostic only: operating systems reuse process ids. |
| `nonce` | 32 lowercase hex, fresh for every server instance. **Echoed only to a request that presented it**, so reading this endpoint never hands the nonce to anyone who did not already hold it. |
| `endpoints` | `http` and `ws`, the addresses this instance actually bound |
| `devices` | what the process declares it opens: `window`, `gamepads`, `audio_output`. A test worker declares all three `false`. Declared by the process (`Config::with_devices`), not observed; absent when it declares nothing |
| `version` | the API version |

A standalone server (`argus run --serve` with no session) answers `managed: false`, every
capability, and no session fields. Anonymously, with no nonce:

```json schema=SessionHandshake
{
  "managed": false,
  "capabilities": ["camera", "capture", "input", "observe", "reload", "snapshot-read", "snapshot-restore", "stop", "time"],
  "anonymous_capabilities": ["camera", "capture", "input", "observe", "reload", "snapshot-read", "snapshot-restore", "stop", "time"],
  "pid": 48213,
  "endpoints": { "http": "http://127.0.0.1:7700", "ws": "ws://127.0.0.1:7701/stream" },
  "version": "1.0.0"
}
```

A request presenting an identity that is not this server's gets `409 session_mismatch` here as
everywhere, so a client verifies with one call: a `200` whose `session_id` and `nonce` are the
ones it holds.

## `GET /` and `GET /schema`

`GET /` lists every endpoint and the API version, for an agent that arrives without this document.

`GET /schema` lists the name of every request and response schema. `GET /schema/{name}` returns one
as JSON Schema draft 2020-12 — `EntitySnapshot`, `EventRecord`, `ContactRecord`, `PerfSample`,
`FrameDelta`, `RaycastRequest`, `RaycastBatch`, `HitView`, `PathRequest`, `PathResult`, `NavMeshView`,
`FrameIndex`, `StateStamp`, `StateStampView`, `WorldStateView`, `RestoreResponse`, `CaptureRequest`, `CaptureResponse`,
`FrameOpts`, `ClipOpts`, `AtOpts`, `ViewsOpts`, `MaterialOpts`, `TimeRequest`, `TimeResponse`, `StepRequest`, `StepResponse`, `InputRequest`,
`InputResponse`, `InputStateView`, `SceneRequest`, `SceneResponse`, `TeleportRequest`, `TeleportResponse`, `UiTreeView`, `UiScreensView`, `UiStatsView`, `UiSchemaView`, `UiActionRequest`,
`UiActionResponse`, `EnvironmentView`, `EnvironmentRequest`, `EnvironmentResponse`, `FeelView`, `StreamMessage`,
`SessionHandshake`, `ErrorBody`.

Generate TypeScript types from these rather than transcribing this document by hand.

## `GET /ui`

The live UI tree, in paint order across the whole screen stack.

| Query | Type | Selects |
|---|---|---|
| `screen` | string | only nodes of this screen |
| `id` | string | this node and its subtree. Takes the qualified id this endpoint reports (`pause:resume`) or the bare author id (`resume`) when exactly one screen answers to it; an ambiguous bare id selects nothing rather than guessing |
| `depth` | integer | at most this many levels below the root, or below `id` when one is given. `0` is that node alone |
| `text` | string | only nodes whose resolved text or string key contains this, case-insensitively |
| `at` | stamp | answer at this committed state, or fail. See **Exact reads** |

Each node carries its stable id, the screen it belongs to, its role and the short tag its derived
id uses, its classes and data attributes, its bounds, `visible`, `clipped`, `focusable`, `focused`,
`focus_visible`, `disabled`, `selected`, `checked`, `hovered`, `pressed`, `hittable`, its paint
order `z` and `depth`, its parent and children, its resolved text with the `meta.strings` key it
came from, and the appearance-affecting half of its resolved style.

**Bounds are reported in both pixel spaces.** `logical` is what layout and pointer coordinates are
in; `device` is what a screenshot is in; `layout` is the flexbox rectangle before any transform,
equal to `logical` for anything untransformed. Reporting both is deliberate: an agent that inferred
the ratio would get it wrong at 1.25 and 1.5, the two common Windows display settings, which are
exactly the ratios where it matters.

`total` counts every node on the stack before the filter, whatever the filter kept, so a caller can
tell "nothing matched" from "nothing is on screen". An `id` nothing answers to is an empty result
rather than a 404 — another screen may have held it a tick ago, and a 404 would make a polling
agent fall over on a menu that closed.

## `GET /ui/screens`

The screen stack, bottom first. Each entry carries its name, `modal`, `dismissible`,
`click_through`, the input context it asks for, whether it wants a cursor, the node holding its
focus, how many nodes it holds, and the props it was last pushed with.

Beside the stack: every `registered` screen whether or not it is up, the `wanted_context` the
topmost input-taking screen asks for, the `active_context` the game is **actually** in, whether a
cursor should be visible, and the root UI `scale`.

`wanted_context` and `active_context` differ exactly when the scene's `meta.input` declares no
context by that name. The engine leaves the game in the context it had rather than switching into
one with nothing bound — which would look to a player exactly like the controls having died — and
reporting both is how an agent sees that happened.

## `GET /ui/stats`

What the game UI cost on the latest frame, and, per screen on the stack, how many layout passes it
has run and what dirtied layout for the last one. It answers "is the HUD laying out every frame,
and why?" with two reads instead of a bisect.

```json schema=UiStatsView
{
  "frame": 4210,
  "ui_ms": 0.21,
  "draw_calls": 7,
  "batches": 2,
  "quads": 118,
  "texts": 31,
  "screens": [
    { "name": "hud", "nodes": 64, "layout_passes": 12, "last_layout_cause": "text", "last_layout_node": "hud:place" },
    { "name": "raceinfo", "nodes": 70, "layout_passes": 3, "last_layout_cause": "styles" }
  ]
}
```

- `draw_calls` are the UI pass's quad rounds plus its text rounds. A new quad round (`batches`)
  starts only where a quad covers a text run below it, so a `draw_calls` above `2` means something
  is painted over a label.
- `layout_passes` counts from the screen's push. Read twice a second apart: a steady screen gives
  the same number; one climbing by the frame rate is laying out every frame.
- `last_layout_cause` is `styles` (a full cascade: a class or a sheet changed), `binding` (a bound
  value a selector reads), `text` (a run was re-measured), `transition` (a size or position is
  animating), `direction`, or `other`. `last_layout_node` names the node when one node caused it.
- Not an exact read: like `GET /perf` it describes the latest frame, so it takes no `?at=`. The
  first read asks the engine to start publishing it; until then nothing is built, and that first
  read answers zeroes.

With the flight recorder on, the same readout lands in the trace: a `ui` span over each UI tick
(its argument the layout passes it ran) and a `ui.layout:<screen>:<cause>` marker inside it for each
screen that laid out (its argument the node count). With the recorder off none of it is recorded or
built.

## `GET /ui/schema`

**The artefact that stops agents guessing**. One machine-readable
document naming everything this engine's UI supports and everything it refuses: every style
property with its syntax, initial value and whether it inherits and animates; every unit,
function, selector form, pseudo-class, at-rule and easing; every role; every design token with
its value; the `ctx.ui.set` binding vocabulary; the three hit-test modes; the screen-level
flags; and every refusal code the UI surface can answer with.

It is generated from the parser's own dispatch tables, not written by hand, so it cannot drift
from what the engine actually parses — a property added to the parser and missing here is a
failing test (`crates/app`'s `every_property_the_parser_applies_is_in_the_document`).

Not an exact read, and it takes no `?at=`. It describes what the build supports, which is a
property of the build and the loaded stylesheets rather than of a committed state.

| Key | What |
|---|---|
| `format` | the style table's own version; goes up when a key changes meaning, not when one is added |
| `version` | the wrapper's version, same rule |
| `source` | `engine`, `mock`, or `none` for a source with no UI runtime |
| `properties`, `refusedProperties` | what the parser applies, and what it refuses with the nearest alternative |
| `units`, `functions`, `selectors`, `pseudoClasses`, `atRules`, `easings` | the rest of the style subset |
| `roles`, `sizeClasses`, `transitionProperties`, `diagnostics` | the runtime's own vocabularies |
| `tokens` | the **project's** token table, primitive and semantic, at the active theme |
| `themes`, `theme`, `locales`, `locale` | what `POST /ui/theme` and `POST /ui/locale` will accept, and what is in force |
| `bindings` | what `ctx.ui.set` writes to, what each takes, and whether it restyles |
| `hitModes` | `auto`, `always`, `none`, how they inherit, and §2.9's click-through rule |
| `components` | the component library's published manifest; **absent** when the project ships none |
| `refusals` | every `ui.*` code this surface raises, with what it means |

A document missing one of `format`, `version`, `source`, `properties`, `selectors`,
`pseudoClasses`, `roles`, `tokens`, `bindings` or `hitModes` is refused as `500` rather than
served short. An agent that reads a support table with no `properties` key concludes the
engine supports no properties, and a wrong answer about what is supported is worse than none.

## `POST /ui/activate`, `/ui/focus`, `/ui/input`, `/ui/theme`, `/ui/scale`, `/ui/locale`, `/ui/push`, `/ui/pop`

Driving the live UI. All six answer the same body, and all six drive **the same events a
player does** — activation is a synthesised pointer press and release at the node's own
visible centre, focus-by-id walks Tab, a direction is the pad's. There is no back door: a
route that reached past the hit test could report a press on a button no player could click,
which is the one failure a UI test surface must not have.

### Refusals are `200`, not errors

> §3: *"Refuses, with a reason, when hidden, disabled, clipped or not hit-testable —* **the
> refusal is itself the test signal.**"

"That button is disabled" is a fact about the screen, not a mistake by the caller, so it comes
back as `ok: false` with a coded `refused` object rather than as an HTTP error — the same
shape of answer `POST /input` gives with its `unbound` list. A **malformed** request is still
`400`: an empty `id`, both `id` and `direction` on `/ui/focus`, more than 64 events, a
non-finite coordinate, a scale outside `0.5..=4.0`.

```json
{ "ok": false, "op": "activate", "frame": 412,
  "refused": { "code": "ui.activate.disabled",
               "message": "pause:resume is disabled, and a disabled node refuses activation",
               "next": "clear it with ctx.ui.set(id, \"disabled\", false) and press it again" } }
```

### The body

| Field | What |
|---|---|
| `ok` | false when the UI refused |
| `op` | which route answered |
| `frame` | the tick it was applied on |
| `refused` | `{ code, message, next }` when `ok` is false |
| `node`, `screen` | what the call resolved to |
| `focus` | `{ screen, node?, visible, moved }` — where focus is now |
| `activated`, `popped` | what the call caused, in order |
| `diagnostics` | `{ code, severity, message, node?, hint? }` raised while it was handled |
| `theme`, `locale`, `scale` | what is in force, from the route that set it |
| `stack` | the screen stack after the call, bottom first. From `/ui/push` and `/ui/pop` |
| `handled`, `consumed` | events routed, and how many the UI took. An event the UI did not take reaches the game |

### `POST /ui/activate` — `{ "id": "pause:resume" }`

Takes the qualified id or the bare one when exactly one screen answers to it. Refuses with:

| Code | When |
|---|---|
| `ui.activate.no-such-node` | no screen on the stack holds that id, or a bare id is ambiguous |
| `ui.activate.hidden` | the node is under `display: none` |
| `ui.activate.disabled` | the node is disabled |
| `ui.activate.clipped` | a clipping ancestor removes every pixel of it |
| `ui.activate.not-hittable` | it answers no hit test — on a click-through screen a node must say `hit="always"` |
| `ui.activate.not-interactive` | neither it nor any ancestor takes activation |
| `ui.activate.occluded` | another node is painted over the point a click would land on; the message names it |
| `ui.activate.not-on-top` | its screen is not the topmost one taking input |

### `POST /ui/focus` — `{ "id": … }` **or** `{ "direction": "up"\|"down"\|"left"\|"right" }`

Exactly one of the two. By id it walks Tab until focus arrives, bounded by the tree's size, so
a focusable node Tab never reaches refuses with `ui.focus.unreachable` — which is the same
signal the critic's unreachable-focus check reports, produced by the same walk. By direction it
is one `Nav`, so gamepad navigation is testable without a gamepad. The answer's `focus` says
what received it.

Also refuses `ui.focus.no-such-node` and `ui.focus.not-focusable`.

### `POST /ui/input` — `{ "events": [ … ] }`

Up to 64 events, applied in order on one tick, **in UI logical pixels** — the space
`bounds.logical` is in, so a caller that read the tree can click a node's centre with no
arithmetic.

| `type` | Fields |
|---|---|
| `pointer_move`, `pointer_down`, `pointer_up`, `click` | `pos: [x, y]`, `button: "primary"\|"secondary"\|"middle"` |
| `key` | `key`, `repeat`. See the names below |
| `nav` | `direction` |
| `activate`, `back` | no fields |
| `text` | `text` — typed characters, into whichever `text-field` holds focus |
| `composition` | `text`, `cursor?: [from, to]`, `commit?` — an IME preedit, or its commit |

`click` is a down and an up at the same point, which is what a person means by one.
Complements `POST /input`, which sends named game actions; this one sends UI events.

**Key names**: `tab`, `shift+tab`, `escape`, `enter`, `space`, `backspace`, `delete`, `left`,
`right`, `up`, `down`, `home`, `end`, `page-up`, `page-down`, `copy`, `cut`, `paste`,
`select-all`, and `scancode:<n>` for anything else.

`home`, `end` and the two page keys drive a **scroll container** when no text field has focus,
and `home` and `end` move a focused field's caret instead — which is what every text editor
does with them. An agent scrolling a list without a pointer sends these.

**A modifier is a prefix on the name**, which is the grammar `shift+tab` already established:
`shift+left` extends a selection, `ctrl+left` and `alt+left` move by word, `cmd+a` selects all,
and `ctrl+c`, `ctrl+x` and `ctrl+v` are the clipboard. There is no separate modifier field, so
nothing a caller already writes changed shape. `ctrl` and `cmd` both mean the platform
accelerator, so one body works on every platform.

**Typing needs a field focused.** A `text` or `composition` event with none is refused with
`ui.input.no-field` and a `next` saying to `POST /ui/focus` first; a *key* with none is not, because
Escape and Tab belong to the screen stack and the focus ring whatever holds focus. `handled`
counts the events the caller sent, not the runtime events each becomes.

**`submitted` lists the fields that submitted** while the call was handled, beside `activated`:
Enter in a single-line field submits rather than activating. `GET /ui` then reports the value on
the node's `field` view, which is how an agent types and reads back.

### `POST /ui/theme`, `/ui/scale`, `/ui/locale`

The screenshot matrix. `theme` takes a name the project's stylesheets declare (`ui.theme.unknown`
otherwise, listing the ones there are). `scale` takes the player's UI-scale multiplier in
`0.5..=4.0` (`ui.scale.out-of-range`). `locale` takes a locale the scene's string table resolves
(`ui.locale.unknown`).

**A locale is a prefix on a string key.** `meta.strings["menu.resume"]` is the base locale's and
`meta.strings["de.menu.resume"]` is German's; resolution tries the prefixed key and falls back to
the unprefixed one, so a project with one language writes what it writes today and a
half-translated one renders the half it has. `GET /ui/schema` lists the locales the table
actually holds. The active theme and locale are written onto every screen root as `data-theme`
and `data-locale`, so a stylesheet can select on either. A node whose text a behaviour set
through `ctx.ui.set` keeps that value across a switch; the localisable surface is the one the
author wrote as a key, and `GET /ui` keeps reporting that key after the switch so the critic can
name the string that overflowed rather than the English it happened to render as.

Full localisation — font fallback chains, plural forms, RTL — is U4.

### `POST /ui/push` — `{ "screen": …, "props"?: …, "replace"?: false }` — and `POST /ui/pop`

The route form of `ctx.ui.push`, `ctx.ui.replace` and `ctx.ui.pop`, refusing with the same
codes (`ui.screen.unknown`, `ui.screen.duplicate`, `ui.stack.empty`). Without them an agent
could read the stack and press what was on it but never open anything, so the first thing
anyone does with a new screen — open it and look at it — needed a behaviour written first.
`/ui/pop` takes no body. Both answer with the resulting `stack`.

## Managed sessions

A server started for one workspace session (`argus_query::serve_managed`) enforces that session
here, on every route, before any handler runs. A standalone server (`argus run --serve` with no
session, `argus serve-mock`) behaves as it always has, except that it too refuses an identity that
is not its own.

These checks stop accidental cross-session control: a stale port, a replaced process, an agent's
client pointed at the human's session. They are not a security boundary against code running as
the same user.

### Capabilities

Each route needs one capability, named exactly as in the workspace contract
([`docs/WORKSPACE-CONTRACT.md`](../../docs/WORKSPACE-CONTRACT.md) §7):

| Capability | Routes |
|---|---|
| `observe` | `GET /`, `/schema`, `/schema/{name}`, `/entities`, `/entity/{id}`, `/events`, `/contacts`, `/perf`, `/trace`, `/navmesh`, `/frame`, `/input`, `/state`, `/ui`, `/ui/screens`, `/ui/schema`, `/ui/stats`, `/environment`, `/feel`, `/save`, `/roads`, `/npc/log`, `/npc/pending`, `/camera/project`, `/camera/ray`, `/profile`, `/profile/frames`, `/profile/trace`, `/profile/captures`, `/profile/capture/{id}`, `/profile/blame`, `/profile/explain`, `/profile/diff`, `/profile/rerun/{id}`; `POST /raycast`, `/path`; `WS /stream` |
| `capture` | `POST /capture`, `GET /frame.png`, `GET /profile/image`, `POST /ui/theme`, `/ui/scale`, `/ui/locale`, `/ui/viewport`, `/profile/capture`, `/profile/detail` |
| `snapshot-read` | `GET /snapshot` |
| `snapshot-restore` | `POST /snapshot`, `POST /entity/{id}/teleport`, `POST /save` |
| `input` | `POST /input`, `/ui/activate`, `/ui/focus`, `/ui/input`, `/ui/push`, `/ui/pop`, `/profile/rerun` |
| `time` | `POST /time` (pause, step, scale); `POST /step` as well, which also needs `input` |
| `reload` | `POST /environment`, `POST /scene` — changing what the running scene is made of without restarting it |
| `camera`, `stop` | no endpoint in this version; the managed runtime enforces them itself |

`GET /session` needs none. `HEAD` needs what `GET` needs.

### The rules

On every request, in order:

1. **An identity the request presents must be this server's.** An `x-argus-session` naming another
   session (or any session, on a standalone server), or an `x-argus-nonce` this server instance did
   not issue, is `409 session_mismatch`, reads included. A header sent twice never matches.
2. **A standalone server** allows everything else.
3. **A managed server, nonce presented:** the route's capability must be in `capabilities`, or
   `403 capability_denied`.
4. **A managed server, no nonce:** the route's capability must be in `anonymous_capabilities`, or
   `428 session_required`.

A session id without the nonce counts as no identity. The session id is public, on
`GET /session`; the nonce is known only to the launcher and whoever it hands the session to.

`WS /stream` also reads `?session=` and `?nonce=`, because a browser websocket cannot set headers.
A header beats the query parameter of the same name, and a parameter sent twice never matches,
whichever value comes first. A refused upgrade is the same error body, before any socket opens.

A nonce that is not this server's, whatever the route:

```json schema=ErrorBody
{ "error": "session_mismatch", "mismatch": "nonce", "message": "the x-argus-nonce on this request was not issued by this server: the process it came from has been replaced, or the request reached a different server. Resolve the session again and repeat the handshake" }
```

The session's own holder, asking for a control the session does not grant:

```json schema=ErrorBody
{ "error": "capability_denied", "capability": "time", "message": "session ses_0123456789abcdef0123456789abcdef (preview) does not grant `time`, which POST /time needs; it grants input, observe" }
```

A plain client, reaching for a managed session's controls:

```json schema=ErrorBody
{ "error": "session_required", "capability": "input", "message": "this server is managed session ses_0123456789abcdef0123456789abcdef (human-play); POST /input needs `input`, which a request without the session's x-argus-session and x-argus-nonce headers does not hold (it holds observe). Connect through a session-aware client" }
```

### Refusals and request bodies

A refused request is answered only once its body has arrived, so the refusal reaches the client
rather than being lost to a connection reset. The server reads and discards the body first, within
two bounds:

- **Size.** The route's own body limit: 64 MiB for `POST /snapshot`, 2 MiB for every other route.
  A request whose `Content-Length` declares more is answered at once, and nothing is read.
- **Time.** Two seconds for the whole body. A client that stops sending partway through gets its
  refusal two seconds later.

Past either bound the refusal is still sent, and the connection then closes with the rest of the
body unread, as it does when an endpoint refuses an over-limit body. A client sending
`Expect: 100-continue` gets `100 Continue`, sends its body, then gets the refusal. The same applies
to every other answer no endpoint handler gives: `GET /session`, and a `404` or `405` for a route
this API does not serve.

The cost: a caller the session refuses can still upload up to the route's limit before it hears so.

### Launch flags

A worker is started for a session with these `argus run` flags. Ids use the workspace contract's
formats, and `serve_managed` refuses one that does not parse. With no `--session-id` the engine
serves standalone.

| Flag | Meaning |
|---|---|
| `--session-id ses_…` | the session this process serves |
| `--session-kind preview\|human-play\|test` | its kind |
| `--job-id job_…` | the owning job; required for `test` |
| `--project-id <uuid>` | the project |
| `--artifact-id art_…` | the build artifact this process runs, echoed for the launcher to check |
| `--capabilities a,b,c` | what the holder of the nonce may do |
| `--anonymous-capabilities a,b` | what a caller with no identity may do; `observe` when absent, nothing when empty |
| `--seed <u64>` | the seed the session's simulation starts from; not part of the handshake |
| `--port 0 --ws-port 0` | bind ephemeral ports and report them on the ready line |

The nonce is never a flag: the engine generates it, so a replacement process can never reuse one.

### What the runtime declares

A managed runtime caps the capabilities it was launched with by the session's kind. That covers `argus run` with the launch flags (built with the app's `managed` feature), an editor Play session, and an editor test worker:

| Kind | `capabilities` (holder) | `anonymous_capabilities` |
|---|---|---|
| `preview` | as launched, capped at `observe`, `capture`, `snapshot-read` | the same cap |
| `human-play` | as launched, capped at `observe`, `capture`, `snapshot-read` | the same cap |
| `test` | as launched, plus `observe` | `observe` |

So no API caller can drive the human's session. With the session's identity, `POST /input`, `POST /time` and `POST /snapshot` are `403 capability_denied`; without it they are `428 session_required`. The human's pause, step, stop and reload are in-process controls in the editor, not API capabilities. A runtime declares the devices it opens in `devices`, and a test worker declares none. It prints its ready line once both listeners are bound and the first snapshot is published. `docs/EDITOR.md` covers the editor's side.

### Connecting to a session

1. Resolve the session by id through whoever owns it: the workspace service, or the launcher that
   read the ready line. Take its `endpoints.http` and its nonce. Never guess or reuse a port.
2. `GET /session` with both headers. Continue only on a `200` carrying the `session_id` and `nonce`
   you hold, and the `artifact_id` or `pid` you expect when you know them.
3. Send both headers on every later request.
4. `409 session_mismatch` means the address now answers for something else. Resolve the session
   again and repeat step 2. Never re-send the same request with an identity copied from the
   server.

`packages/observe`'s `connectSession` does exactly this, and never sends a request other than the
handshake before step 2 passes.

### The ready line

A serving process, managed or standalone, prints exactly one line on stdout, and flushes it, once
both listeners are bound:

```text
argus-ready {"managed":true,"session_id":"ses_0123456789abcdef0123456789abcdef","kind":"test",...,"nonce":"9f86d081884c7d659a2feaa0c55ad015","endpoints":{"http":"http://127.0.0.1:41873","ws":"ws://127.0.0.1:41874/stream"},"version":"1.0.0"}
```

The JSON is a `SessionHandshake`, nonce included. The process that started the server reads it
from the pipe. That is how a launcher learns the ports a worker bound with `--port 0 --ws-port 0`,
with no pick-a-free-port-then-close race, and the nonce it verifies with.

### Compatibility

- Additive under the versioning rule: one endpoint, two request headers, three error codes with two
  optional `ErrorBody` fields, one schema (with an optional `devices` object). `API_VERSION` stays
  `1.0.0`.
- A client that sends no identity to a standalone server sees no change at all.
- A plain client pointed at a managed session can still observe it, with the default
  `anonymous_capabilities` of `["observe"]`, and gets `428 session_required` rather than silence
  for anything else.
- `409`, `403` and `428` occur only when a request presents an identity or the server is managed.

### Implementers

```rust
let session = ManagedSession::new(session_id, SessionKind::Test, [Capability::Observe, Capability::Input, Capability::Time])
    .with_job(job_id)
    .with_artifact(artifact_id);
let config = Config::on_ports(0, 0)
    .with_devices(SessionDevices { window: false, gamepads: false, audio_output: false });
let server = argus_query::serve_managed(source, config, session)?;
println!("{}", server.ready_line());
```

`serve_managed` refuses a declaration the workspace contract would refuse: a malformed id, a `test`
session with no job, a capability listed twice. Controls with no raw endpoint check
`server.session()`, as in `session.allows(Capability::Reload)`. `argus_query::serve_mock_session`
is the mock behind the same guard, and `cargo run -p argus-query --example session_mock` runs it as
a process.

---

## The `QuerySource` contract

This half is for `crates/app`, not for API clients.

`crates/query` knows nothing about `World`, `EntityStore`, Rapier or Recast. It talks to one trait,
and the app implements it — or, far more likely, uses the implementation this crate already ships.

```rust
pub trait QuerySource: Send + Sync + 'static {
    // Served from the published snapshot. Must not block the simulation.
    fn frame(&self) -> u64;
    fn entities(&self, sel: &EntitySelector) -> Vec<EntitySnapshot>;
    fn entity(&self, id: &str) -> Option<EntitySnapshot>;
    fn events(&self, filter: &EventFilter) -> Vec<EventRecord>;
    fn contacts(&self) -> Vec<ContactRecord>;
    fn perf(&self) -> PerfSample;
    fn trace(&self, q: &TraceQuery) -> TracePage;
    fn subscribe(&self) -> StreamSubscription;
    fn frame_png(&self) -> Option<Arc<Vec<u8>>> { None }


    // Answered by the simulation thread. Each defaults to QueryError::Unsupported → 501.
    fn navmesh(&self) -> Result<NavMeshView, QueryError>;
    fn world_state(&self) -> Result<WorldStateView, QueryError>;
    fn restore(&self, state: WorldStateView) -> Result<RestoreResponse, QueryError>;
    fn raycast(&self, req: &RaycastRequest) -> Result<Option<HitView>, QueryError>;
    fn raycast_batch(&self, reqs: &[RaycastRequest]) -> Result<Vec<Option<HitView>>, QueryError>; // default: one raycast per ray
    fn path(&self, req: &PathRequest) -> Result<PathResult, QueryError>;
    fn capture(&self, req: &CaptureRequest) -> Result<CaptureResponse, QueryError>;
    fn set_time(&self, req: &TimeRequest) -> Result<TimeResponse, QueryError>;
    fn send_input(&self, req: &InputRequest) -> Result<InputResponse, QueryError>;
}
```

Return `QueryError::no_such_entity(id)` from `capture` and `send_input` when the world has no such
entity, rather than mapping the failure to `QueryError::Internal`. `Internal` is a `500` and tells
the caller the engine broke; this is a `404` and tells them they asked for the wrong thing. The
server already refuses an id missing from the published snapshot, so the trait only has to cover an
entity despawned between that check and the command being drained.

### The rule

> **The simulation never waits on the query server, and the query server never touches the world.**

Two mechanisms carry everything across.

**A published snapshot, for reads.** Once per **rendered frame** the app fills the entity buffer the
publisher lends it and publishes a `WorldSnapshot`. On the 10,000-entity fixture that is zero
allocations and 0.09 ms, against a simulation frame of 0.57 ms.

Publish per rendered frame, not per tick. At a time scale of 10 the simulation runs ten fixed ticks
per frame, and publishing each would do ten times the work for a picture nobody looked at. A
snapshot at most one rendered frame old is no staler than the up-to-one-tick latency the command
queue already costs, and `x-argus-frame` says exactly which tick it describes.

Events and the trace ring are the exception and stay **per tick**. An event published per frame is
an event dropped, and a trace delta is per tick by definition. Both have their own path and neither
goes through the snapshot.

**A command queue, for everything else.** `POST /raycast`, `/path`, `/capture`, `/time`, `/input`
and `GET /snapshot` need the live world. The handler pushes a `Command` carrying a one-shot reply
channel and waits on a blocking thread; the app drains the queue once per tick and replies. A live
op therefore costs up to one tick of latency, 16.7 ms worst case at 60 Hz, which is inside the
30 ms budget and is the price of never blocking the simulation. If nothing drains the queue the
caller gets `503` after `Config::command_timeout` rather than hanging.

### Identity and state

Publishing is cheap because `EntitySnapshot`'s fields split in two.

**Identity** is `id`, `name`, `tags`, `components`, `parent` and `static`. It changes only when the
scene's structure does. Every one is a shared handle: `IStr` wraps `Arc<str>`, `tags` and
`components` wrap `Arc<[T]>`. Build them when a scene loads and keep them beside your ECS columns.

**State** is `position`, `rotation`, `scale`, `velocity`, `script`, `anim` and `frame`. It changes
every frame, and every one is `Copy` or an `Arc` you swap.

So a frame's publish overwrites state and never touches identity. Two things make that add up to
zero allocations:

- **`IStr::from(&str)` interns.** A table lookup under a read lock, no allocation on a hit. Use it
  for the bounded vocabularies that come out of scene data: ids, names, tags, component type names.
  `IStr::from(String)` deliberately does *not* intern — it takes the allocation you already made —
  so arbitrary runtime text cannot grow the table without bound.
- **The publisher lends you the buffer.** `entity_buffer` hands back the one from an earlier
  publish, still holding its contents, uniquely owned. You overwrite and hand it back.

Measured on the 10,000-entity fixture, per frame:

| shape | allocations | bytes |
|---|---|---|
| lend the buffer, overwrite state | 0 | 0 |
| rebuild the table from cached handles | 2 | 4,800,016 |
| rebuild from `String` columns, uninterned | 150,002 | 21,489,936 |

### Wiring it up

```rust
let (publisher, mut commands, source) = argus_query::channel();
let server = argus_query::serve(Arc::new(source), Config::default())?;

loop {
    let ticks = world.advance(real_dt);          // one or more fixed ticks

    // Per tick: events and the trace ring. Neither may be skipped.
    for _ in 0..ticks {
        for ev in world.take_events() {
            publisher.publish_event(world.frame(), world.time(), to_event_kind(ev));
        }
        let trace = publisher.trace();
        let deltas = if trace.is_keyframe(world.frame()) { all_movable(&world) } else { dirty(&world) };
        trace.record_frame(world.frame(), deltas, std::iter::empty());
    }

    world.render(world.alpha());

    // Answer whatever the agent asked for while the frame was running.
    commands.drain(|cmd| match cmd {
        Command::Raycast(rays, reply) => reply.respond(rays.iter().map(|r| cast(&world, r)).collect()),
        Command::Time(req, reply)    => reply.respond(apply_time(&mut world, &req)),
        other                        => other.reject_unsupported(),   // 501, not a timeout
    });

    // Per rendered frame: the snapshot. Zero allocations in the steady state.
    let mut buf = publisher.entity_buffer(world.len(), || build_table(&world));
    for (i, e) in buf.entities_mut().iter_mut().enumerate() {
        e.set_state(world.frame(), position(&world, i), rotation(&world, i), velocity(&world, i));
    }
    publisher.publish(WorldSnapshot {
        frame: world.frame(),
        time: world.time(),
        perf: perf_window.sample(),
        entities: buf.into_entities(),
        ..publisher.latest()       // contacts, navmesh, png reused: one refcount bump each
    });

    if world.frame() % 60 == 0 {
        publisher.publish_perf(perf_window.sample());
    }
}
```

`build_table` runs only when a buffer has to be created: the first publish, a change in entity
count, or every pooled buffer still held by an in-flight reader. `EntityBuffer::was_rebuilt` says
which happened, so an app that wants to know it is allocating every frame can count it. Build it
from your cached identity handles, not from your `String` columns.

`Command::reject_unsupported()` is the important one during the build: it answers a command for a
subsystem that does not exist yet with `501` naming the endpoint, immediately, instead of leaving
the caller to wait out the timeout.

### Developing against it with no engine

`MockSource` implements the whole trait over a generated world. It is in the default build on
purpose:

```rust
let (server, source) = argus_query::serve_mock(10_000)?;
```

Ten thousand entities with tags, components, velocities, script state, animator state and a
navmesh, a deterministic generator, working `/raycast` and `/path`, real PNG bytes from `/capture`,
and a `/stream` that delivers. `packages/observe` and the critic can be written and tested against
it with no renderer, no GPU and no scene load.

---

## What this API is not

These are primitives. `/contacts` answers "what is touching at frame 412". It does not answer "the
player fell through the floor" — that is composed in `packages/observe`, in TypeScript, on top of
these calls, and it iterates in a second because nothing here has to recompile for it.

