Talos docs

Talos raw query API — v1.0.0

Since 0.6

Frozen. This document is the contract packages/observe (step 06) and the critic (steps 07–08) are written against.

Plain text

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:

HTTPhttp://127.0.0.1:7700
WebSocketws://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.

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.
  • 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.
  • Frames and time. frame is the fixed-tick counter, 60 Hz, starting at 0. time is simulated seconds since the scene loaded.

Response headers

HeaderOnMeaning
x-argus-frameevery responsethe frame this response describes
x-argus-stampevery responsethe committed state this response describes, artifact.frame.revision.structure
x-argus-apievery responsethe API version, 1.0.0
x-argus-trace-rangeGET /tracefirst-last frame indices in this page
x-argus-trace-truncatedGET /traceframes dropped from the oldest end to fit the budget
x-argus-trace-deltasGET /tracetransform deltas in this page
x-argus-totalGET /entities, GET /feelhow many entities (for /feel, animators) matched, before the page cut them
x-argus-next-cursorGET /entities, GET /feelthe cursor for the next page; absent on the last

Request headers

HeaderOnMeaning
x-argus-sessionany requestthe session id the request is meant for
x-argus-nonceany requestthe 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.

Errors

Every non-2xx response has the same body.

{ "error": "not_found", "message": "no entity with id \"nope\"; GET /entities lists what the scene has" }
errorStatusMeans
bad_request400the request parsed but a value is wrong: a non-finite float, a zero ray direction, a malformed filter. The message names the field.
not_found404no such entity, schema, or route
unsupported501this build has not wired up the subsystem behind this endpoint. The message names the endpoint.
timeout503the simulation did not answer a queued command before the deadline: it is paused under a debugger, blocked on a load, or shut down
unavailable503the command queue is full, or the simulation dropped the command
at_capacity503a 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_stamp409an 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.
internal500the source failed while answering
session_mismatch409the 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_denied403the request is this session's, but the session does not grant what the endpoint needs. capability names it.
session_required428a managed session refused a request that presented no identity. capability names what the endpoint needs.

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:

x-argus-stamp: 1.130.457.3

Four counters, artifact.frame.revision.structure:

Changes whenWhat a client does about it
artifactthe applied scene changes: load, restart, hot reload, patchdrop every cached id, path and capture
framethe simulation commits a tickthe same number as x-argus-frame
revisionany committed state change, ticked or notcompare this to ask "is this the state I already have"
structurethe published identity set may differ: spawn, despawn, replacement, restorerefetch 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:

{ "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=

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

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 deadline200, and x-argus-stamp equals what was asked for
the state has been passed, or belongs to a scene since reloaded409 stale_stamp, carrying wanted and current
the deadline passes with the state still ahead503 timeout

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

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 ceilingRefused with
live WS /stream readers32at_capacity on the upgrade, before the handshake completes
exact operations in flight8at_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:

{ "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.

ParameterShapeMeaning
ida,b,coin#*the entity's id is one of these; an item with * or ? is a pattern
namepatternthe entity's name matches (* any run of characters, ? exactly one)
tagstringthe entity carries this tag exactly
componentstringthe entity carries a component of this type
nearx,y,z,radiusthe entity's position is within radius metres of the point
insideminx,miny,minz,maxx,maxy,maxzthe entity's position is inside this box, bounds included
fieldstransform,animkeep only these top-level fields of each entity; id is always kept
limitintegerreturn at most this many
cursorthe last page's x-argus-next-cursorstart 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 /entities?id=crab*&fields=transform,anim&limit=2

[
  { "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

[
  {
    "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:

{
  "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 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 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 /entity/{id}

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

GET /entity/e11

{
  "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.

ParameterShapeMeaning
sinceintegeronly events with frame >= since. Default 0.
after_seqintegeronly events with seq > after_seq. The precise polling cursor.
filterstringmatch type or the custom/trigger event name. Comma-separated for alternatives: filter=trigger,door-opened.
entitystringonly events naming this entity id
limitintegerat 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.

[
  {
    "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.

[
  {
    "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.

{
  "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
  }
}

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.

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.

CounterWhat it counts
audio_playctx.audio.play(asset) for an asset with no registered sound
particles_emitctx.particles.emit(preset) outside the eight presets the contract defines
hud_setctx.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_overridectx.nav.goTo(target, speed) with a speed — the crowd has no per-agent speed setter
audio_busa bus meta.audio does not declare (the cue still plays, on the default bus)
audio_snapshotctx.audio.snapshot(name) with no such snapshot in meta.audio.snapshots
audio_musicctx.audio.music.play(layer) with no such layer in meta.audio.music.layers
body_pusha 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_ignoreda 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_ignoreda 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:

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.

ParameterShapeMeaning
framesintegerhow many frames to return, newest last. Default 60, maximum 300.
beforeintegeronly 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:

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

GET /trace?frames=2

[
  {
    "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.

{ "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").

fielddefault
excludenonean entity id, or a list of them, the ray passes straight through. An id the scene does not have is a 404
exclude_tagsnonea tag, or a list; every entity carrying one is passed through too
triggerstruewhether 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:

{ "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:

{ "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:

{
  "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:

null

POST /path

Query the navmesh.

{ "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>>:

[[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:

{
  "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:

{
  "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.

{
  "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).

{
  "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:

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.

ParameterShapeMeaning
fresh1, true, yesrender 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:

HeaderWhat it means here
x-argus-framethe frame the image shows — not the frame the server is on
x-argus-stampthe 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. 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.

{
  "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.

{
  "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: 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.

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.

{
  "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": "" }
}
{ "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:

{ "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.

{ "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.

{
  "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
}

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

{ "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.

{ "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.

{ "kind": "views", "entity": "player" }

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

{ "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:

{
  "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:

[
  { "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.

{ "kind": "material", "material": "brick", "shape": "cube" }
{ "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.

{
  "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:

{ "pick": "ids[y * width + x], where 0 means no entity covered that pixel" }
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.

{ "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.

{ "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.

{
  "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 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"):

{
  "actions": [
    { "type": "steer", "dir": [0.0, -1.0], "speed": 3.0, "frames": 30 }
  ]
}
{ "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:

{ "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:

{
  "actions": [
    { "type": "mouse", "delta": [240.0, 0.0], "frames": 10 }
  ]
}

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.

{
  "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:

{
  "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.

{
  "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.

{ "scene": "scenes/track-meadow.scene.json" }
{ "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.

{
  "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.

{
  "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.

{ "position": [2.0, 0.0, -7.0], "yaw": 3.14159 }
{ "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:

{ "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).

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.

{
  "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.

{ "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.

{
  "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 /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:

ParameterShapeMeaning
sectioncamera,animatorskeep only these top-level sections: player, camera, animators, audio, animator_misses, and clips (below); frame is always kept
id (or entity)hero,crab*only the animators and animator misses of these entities; patterns as on /entities
namepatternof entities whose name matches
tagstringof entities carrying this tag
fieldsstates,clipskeep 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, cursorinteger, the last page's x-argus-next-cursorpage 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.

{
  "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, "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 }
  },
  "animator_misses": [{ "entity": "hero", "call": "set", "name": "Run", "count": 12, "states": ["idle", "jump", "walk"] }]
}
  • 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.
  • 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). 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.

RouteNeedsAnswers
GET /profileobserveenabled, ring {capacity_spans, spans_written, span_bytes}, frames_kept, retention_s, spikes_seen, spikes_suppressed, capture_dir, auto_capture, captures
GET /profile/frames?last=observeframes: 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>observeChrome trace JSON (opens in ui.perfetto.dev) of the live ring or of a saved capture
POST /profile/capture {reason?}capturesaves the ring now: id, path, frames, spans, writing: true. The file is written on a worker thread
GET /profile/capturesobservedir, and captures newest first: id, bytes, modified_unix_s, trigger, scene
GET /profile/capture/{id}observeone capture's header, frames, spans, names, first_frame, last_frame, duration_ms, trace
GET /profile/blame?by=&limit=&metric=observewho 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?}captureturns 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=observewhat 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=observecapture 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?}inputreplays 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}observestate (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.

ParameterShapeMeaning
filterstringsame matching as GET /events?filter=. Applies to event messages only.
entitystringonly events naming this entity id
framesboolalso send a frame message every tick. Off by default: 60 messages a second for something GET /frame answers on demand.
no_perfboolsuppress 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:

{ "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.

{ "type": "event", "event": { "seq": 7, "frame": 131, "time": 2.183, "type": "trigger", "volume": "door-volume", "other": "player", "event": "door-opened", "entering": true } }
{ "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 } } }
{ "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:

{ "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:

{
  "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"
}
FieldMeaning
managedwhether this server enforces a session's capabilities
session_id, kindthe workspace contract's SessionId and SessionKind (preview, human-play, test). Absent on a standalone server.
project_id, artifact_id, job_idwhat the launcher declared. The engine echoes them and computes none. A test session always has job_id.
capabilitieswhat a request presenting this server's nonce may do, sorted
anonymous_capabilitieswhat a request presenting no identity may do, sorted
pidthe serving process. Diagnostic only: operating systems reuse process ids.
nonce32 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.
endpointshttp and ws, the addresses this instance actually bound
deviceswhat 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
versionthe API version

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

{
  "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, 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.

QueryTypeSelects
screenstringonly nodes of this screen
idstringthis 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
depthintegerat most this many levels below the root, or below id when one is given. 0 is that node alone
textstringonly nodes whose resolved text or string key contains this, case-insensitively
atstampanswer 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/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.

KeyWhat
formatthe style table's own version; goes up when a key changes meaning, not when one is added
versionthe wrapper's version, same rule
sourceengine, mock, or none for a source with no UI runtime
properties, refusedPropertieswhat the parser applies, and what it refuses with the nearest alternative
units, functions, selectors, pseudoClasses, atRules, easingsthe rest of the style subset
roles, sizeClasses, transitionProperties, diagnosticsthe runtime's own vocabularies
tokensthe project's token table, primitive and semantic, at the active theme
themes, theme, locales, localewhat POST /ui/theme and POST /ui/locale will accept, and what is in force
bindingswhat ctx.ui.set writes to, what each takes, and whether it restyles
hitModesauto, always, none, how they inherit, and §2.9's click-through rule
componentsthe component library's published manifest; absent when the project ships none
refusalsevery 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.

{ "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

FieldWhat
okfalse when the UI refused
opwhich route answered
framethe tick it was applied on
refused{ code, message, next } when ok is false
node, screenwhat the call resolved to
focus{ screen, node?, visible, moved } — where focus is now
activated, poppedwhat the call caused, in order
diagnostics{ code, severity, message, node?, hint? } raised while it was handled
theme, locale, scalewhat is in force, from the route that set it
stackthe screen stack after the call, bottom first. From /ui/push and /ui/pop
handled, consumedevents 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:

CodeWhen
ui.activate.no-such-nodeno screen on the stack holds that id, or a bare id is ambiguous
ui.activate.hiddenthe node is under display: none
ui.activate.disabledthe node is disabled
ui.activate.clippeda clipping ancestor removes every pixel of it
ui.activate.not-hittableit answers no hit test — on a click-through screen a node must say hit-test: always
ui.activate.not-interactiveneither it nor any ancestor takes activation
ui.activate.occludedanother node is painted over the point a click would land on; the message names it
ui.activate.not-on-topits 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.

typeFields
pointer_move, pointer_down, pointer_up, clickpos: [x, y], button: "primary"|"secondary"|"middle"
keykey, repeat. See the names below
navdirection
activate, backno fields
texttext — typed characters, into whichever text-field holds focus
compositiontext, 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 §7):

CapabilityRoutes
observeGET /, /schema, /schema/{name}, /entities, /entity/{id}, /events, /contacts, /perf, /trace, /navmesh, /frame, /input, /ui, /ui/screens, /ui/schema, /environment; POST /raycast, /path; WS /stream
capturePOST /capture, GET /frame.png, POST /ui/theme, /ui/scale, /ui/locale
snapshot-readGET /snapshot
snapshot-restorePOST /snapshot, POST /entity/{id}/teleport
inputPOST /input, /ui/activate, /ui/focus, /ui/input, /ui/push, /ui/pop
timePOST /time (pause, step, scale); POST /step as well, which also needs input
reloadPOST /environment, POST /scene — changing what the running scene is made of without restarting it
camera, stopno 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:

{ "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:

{ "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:

{ "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.

FlagMeaning
--session-id ses_…the session this process serves
--session-kind preview|human-play|testits 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,cwhat the holder of the nonce may do
--anonymous-capabilities a,bwhat 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 0bind 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:

Kindcapabilities (holder)anonymous_capabilities
previewas launched, capped at observe, capture, snapshot-readthe same cap
human-playas launched, capped at observe, capture, snapshot-readthe same cap
testas launched, plus observeobserve

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:

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

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.

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:

shapeallocationsbytes
lend the buffer, overwrite state00
rebuild the table from cached handles24,800,016
rebuild from String columns, uninterned150,00221,489,936

Wiring it up

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:

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.