How an external client controls one or more agents in a running WARREN simulation. Design rules:
- Structured data is authoritative. The envelope's
observationobject is the wire truth;textis a rendered convenience for LLM prompting. - One unanswered observation per agent. The engine is conservative: a claimed agent's wake blocks its batch until answered or expired.
- Delivery is at-least-once; acceptance is exactly-once. Envelopes may
be redelivered after reconnects. Commands carry client-minted
command_ids; the server deduplicates accepted commands and replays the original receipt asduplicate. Non-accepted verdicts (rejected/stale/expired) are deterministic and side-effect-free, so they do not occupy theircommand_id: a retry is re-judged against current state (an unchanged retry gets the same verdict; a corrected payload under the same id can be accepted). - Leases, not sessions. A claim grants a lease; reconnecting with the
same lease token resumes exactly where you were (including redelivery of
the outstanding envelope). Leases expire: any authenticated request for
the agent (claim/briefing/poll/submit/receipt) refreshes a TTL
(server-configurable, default 300 s); past it the agent is reclaimable
with a fresh token.
POST …/release?lease=<token>frees the agent immediately.
Transport: plain HTTP + long-polling (GET …/observations?wait=25).
WebSockets are a later acceleration, not the canonical protocol.
POST /v1/runs run: created ─→ running ─→ ended
│
POST /v1/runs/{run}/agents/{a}/claim agent: unclaimed ─→ leased
│ ▲ │ lease expiry /
▼ └──────────┘ explicit release
GET …/briefing (once; cache by revision)
GET …/observations?after=<seq>&wait=<s> ┌──────────────────────┐
│ │ observation: │
▼ │ outstanding ─→ answered
POST …/commands {command_id, observation_id, …} │ │ (exactly one
│ │ ├─→ expired accepted
▼ │ └─→ superseded command)
receipt: accepted | duplicate | rejected └──────────────────────┘
| stale | expired
│
GET …/commands/{command_id} (poll outcome: engine accepted/rejected/…)
Per-observation: exactly one command is ever accepted. Late commands for an
expired observation get expired (the receipt detail says why: decision
deadline vs. run ended); commands for a superseded observation get stale;
resubmission of an accepted command_id gets duplicate carrying the
original receipt's content, while a resubmitted non-accepted command_id is
re-judged. The observation's closure status is judged before payload
validation: a malformed command answering an expired observation gets
expired, not rejected. Payload type errors (e.g. a string sleep_ms,
a non-string action) are rejected with a teaching detail and recorded
like any other verdict; non-string command_id/observation_id cannot be
deduplicated at all and are refused with HTTP 422.
POST /v1/runs?scenario=bridge_repair&seed=3 body:
{"controlled_agents": ["engineer-1"], "npc_policy": "solution", "pace": null}Agent control is explicit at creation: controlled_agents are remote,
npc_policy: "solution" attaches the scenario's bundled runners to everyone
else, "none" leaves them unassigned (they no-op).
POST /v1/runs/run-1/agents/engineer-1/claim →
{"lease": "l-3f9c…", "briefing_revision": 1}GET /v1/runs/run-1/agents/engineer-1/briefing?lease=l-3f9c… →
{
"briefing_id": "run-1/engineer-1",
"revision": 1,
"content_hash": "sha256:…",
"agent_id": "engineer-1",
"role": "engineer",
"name": "Sam the engineer",
"scenario": "bridge_repair",
"premise": "Repair the storm-damaged bridge and resupply the clinic.",
"objectives": [
{"id": "repair", "description": "Restore the bridge's structural integrity.",
"status": "pending", "deadline_ms": null, "progress": {"current": 0, "target": 2}}
],
"tool_catalog": [
{"name": "move_to", "description": "Walk to a place, however far.",
"lane": "body", "durative": true,
"input_schema": {"type": "object",
"properties": {"dest": {"type": "string"}},
"required": ["dest"], "additionalProperties": false}}
],
"conventions": {
"grid_naming": "cells are '<space>:<x>,<y>'; x grows east, y grows south",
"sight_radius": 0,
"budgets": {"max_sight": 40, "max_messages": 12, "max_inventory": 20},
"decision": "answer each observation with exactly one command: an action, a sleep, or a noop"
},
"notes": ""
}GET /v1/runs/run-1/agents/engineer-1/observations?after=-1&wait=25&lease=l-3f9c… →
{
"run_id": "run-1",
"agent_id": "engineer-1",
"observation_id": "engineer-1#0",
"seq": 0,
"sim_time_ms": 0,
"reason": "start",
"deadline_unix": 1769812345.1,
"briefing_revision": 1,
"observation": {"agent": "engineer-1", "time": 0, "busy": [], "you": {"…": "…"},
"place": {"…": "…"}, "tools": [{"action": "take", "…": "…"}]},
"text": "[t=0s] You are Sam the engineer …",
"affordances": [{"action": "take", "params": {"item": {"choices": ["toolbox-1"]}},
"suggestions": [{"item": "toolbox-1"}]}]
}POST /v1/runs/run-1/agents/engineer-1/commands?lease=l-3f9c… body:
{"command_id": "c-7d21", "observation_id": "engineer-1#0",
"action": "take", "args": {"item": "toolbox-1"}}→ receipt (acceptance settles immediately; outcome fills in after
adjudication and is polled at GET …/commands/c-7d21):
{"command_id": "c-7d21", "observation_id": "engineer-1#0",
"status": "accepted", "detail": "", "outcome": "pending", "outcome_reason": ""}Resubmitting the same body (client retry after a crash) →
{"command_id": "c-7d21", "…": "…", "status": "duplicate",
"outcome": "accepted", "outcome_reason": ""}- Each envelope carries
deadline_unix(run-configurable, default 60 s wall). Past it the engine records a noop for that wake and the observation isexpired; the agent's next envelope arrives on its next wake. - A run that completes/fails emits its terminal status on the spectator
stream and
GET /v1/runs/{run}showsstatus; observation polls and claims on an ended run return410 Gonewith the terminal status in the detail. A run whose simulation task dies unexpectedly gets statuscrashed(never a stalerunning). - Version negotiation: every
/v1response carriesX-Warren-Protocol: warren-agent/0. Clients should send the same header (orprotocol=on claim); if the request header is present and does not match, the server refuses with409. A request without the header is accepted. - Spectator stream: a WebSocket subscriber that falls too far behind
receives a
{"kind": "lagged", "cursor": …}frame and is closed with code1013; it can reconnect with?since=<cursor>to resume.