API reference
Base URL: https://sandworld.dev/api/v1. All bodies are JSON. All responses are JSON except dataset downloads.
Authentication
The public sandbox needs no key: anyone can run the public environments, rate-limited per IP. Create an API key in the workspace (API keys) to get higher limits, keep episodes private to your organization, and download datasets. Send it as a bearer token:
Authorization: Bearer sw_live_…Keys carry scopes — environments, episodes, projects, datasets, evals — and can be revoked at any time. Episodes created with a key are visible only to that organization.
Endpoints
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/environments | List environments with task and tool counts. |
GET | /api/v1/environments/{slug} | Policy, tools (JSON schema), tasks, and tool definitions for Anthropic, OpenAI and MCP. |
GET | /api/v1/environments/{slug}/qa | Verifier QA report: probes run against every task's verifier. |
POST | /api/v1/environments/{slug}/episodes | Start an episode (reset). Body: task_id?, seed?, agent_name?, signals?, fault_profile?, user_simulator? |
POST | /api/v1/episodes/{id}/step | Call a tool. Body: tool, arguments. Returns the observation. |
POST | /api/v1/episodes/{id}/submit | Close the episode and run the verifier. |
GET | /api/v1/episodes/{id} | Episode status, action log and verification result. |
GET | /api/v1/episodes/{id}/trajectory | The episode as an ATIF v1.8 trajectory (Harbor's trajectory format). |
GET | /api/v1/environments/{slug}/tasks/{task_id}/harbor?seed=… | A Harbor task package (.tar.gz, beta). API key with the environments scope. |
GET | /api/v1/projects/{id}/export?format=… | Download a data project's dataset (API key with the datasets scope). |
Start an episode
POST /api/v1/environments/coinpurse/episodes
{ "task_id": "cp-12", "seed": 2, "agent_name": "my-agent-v3" }
201 Created
{
"episode_id": "ep_…",
"environment": "coinpurse",
"environment_version": "1.1.0",
"task_id": "cp-12",
"seed": 2,
"instruction": "…what the user wants…",
"policy": "…rules the agent must follow…",
"tools": [{ "name": "list_requests", "description": "…", "parameters": { "type": "object", … } }, …],
"next": { "step": "/api/v1/episodes/ep_…/step", "submit": "/api/v1/episodes/ep_…/submit" }
}Omit task_id for free play. The same seed always produces the same starting state; different seeds vary prices, stock and background records so agents can't memorise answers.
Call a tool
POST /api/v1/episodes/{id}/step
{ "tool": "pay_request", "arguments": { "request_id": "rq_…" } }
200 OK
{ "step": 4, "tool": "pay_request", "ok": true, "result": { … }, "at": "2026-03-05T18:00:28.000Z" }
200 OK (the app refused — returned as data, not an HTTP error)
{ "step": 5, "tool": "send_money", "ok": false, "error": { "code": "daily_limit", "message": "…" } }Simulated time advances seven seconds per step, so timestamps are deterministic. An episode allows up to 200 steps; each task also has a step budget that counts toward verification.
Training: step signals and fault injection
Two reset options are for RL training. signals: true makes every step also return dense signals computed from the verifier — use them to shape rewards, and never show them to the policy, or it learns to read the grader. fault_profile makes specific calls fail deterministically, so agents learn to retry safely.
POST /api/v1/environments/coinpurse/episodes
{ "task_id": "cp-04", "seed": 1, "signals": true, "fault_profile": "timeout_after_commit" }
POST /api/v1/episodes/{id}/step →
{ "step": 3, "tool": "pay_request", "ok": false,
"error": { "code": "timeout", "message": "504 Gateway Timeout: … may or may not have been applied." },
"signals": { "schema_valid": true, "state_mutated": true, "progress": 0.5, "progress_delta": 0.25,
"reference_progress": 0.67, "reference_progress_delta": 0.33,
"collateral_violation": false, "fault": "timeout_after_commit" } }| fault_profile | What fails | The right move |
|---|---|---|
transient_error | The first call to the task's committing tool returns 503 and changes nothing. | Retry. |
rate_limited | The first two calls of the episode return 429 and change nothing. | Wait and retry. |
timeout_after_commit | The first call to the committing tool goes through but reports a timeout. | Check the state before retrying. A repeated write fails the extra no-duplicate-write check. |
progress is the weighted share of the task's own checks that pass after the step. reference_progress is the share of the reference solution's changes made so far, in any order — dense even where a task's checks only pass at the end. collateral_violation means the step changed something no correct solution touches, even midway. Training episodes stay out of evals and leaderboards.
Simulated users
On tasks where the request leaves out a detail only the user knows, the episode's tools include ask_user. By default a scripted user answers from the task's hidden facts by topic. With "user_simulator": "llm" an LLM plays the person instead — conditioned on the task's persona, the original request and the same hidden facts, revealing only what it's asked — on servers that set SW_USER_SIM to a model id. Each reply is cached and kept with the episode, so it replays exactly; the verifier is the same either way. The benchmark runner takes --user-sim <agent id>.
Run every twin locally
runtime/dist/sandworld-server.mjs serves the same API for every world from one Node process, in memory, with no keys and no rate limits — for rollouts at volume, or for evaluating inside your own network. Run more processes on more ports to scale out.
npm run build:runtime && node runtime/dist/sandworld-server.mjs --port 8787
export SANDWORLD_API_URL=http://127.0.0.1:8787/api/v1
# extra endpoint: step many episodes in one request
POST /api/v1/batch { "calls": [{ "episode_id": "ep_…", "tool": "get_balance", "arguments": {} }, …] }One process handles thousands of steps per second through /batch. The Python client's SandworldEnv works against it unchanged; see clients/python/examples/grpo_rollouts.py for GRPO-style rollout groups with shaped rewards.
Submit and verify
POST /api/v1/episodes/{id}/submit
{
"episode_id": "ep_…",
"pass": false,
"score": 0.8,
"steps": 9,
"write_steps": 3,
"tool_errors": 1,
"checks": [
{ "id": "copy-declined", "label": "The duplicate request was declined", "pass": true },
{ "id": "no-collateral", "label": "No collateral changes outside what the task requires", "pass": false,
"detail": "unexpected: contacts[+c-…]" },
…
],
"failure_kind": null,
"mask_sample": false
}pass requires every weighted check to pass. score is the weighted share of passing checks — useful as a dense reward, but read it together with pass: on tasks whose right answer is to refuse, an agent that does nothing can still collect partial credit.
If the verifier itself can't run, submit returns 503 verifier_unavailable with failure_kind: "infra" and mask_sample: true. Drop that sample from training or eval statistics rather than scoring it as a failure.
Use Claude as the agent
Tool definitions are served in the Messages API shape, so a loop takes a few lines:
import anthropic, requests
API = "https://sandworld.dev/api/v1"
client = anthropic.Anthropic()
ep = requests.post(f"{API}/environments/quickbasket/episodes", json={"task_id": "qb-08", "seed": 3}).json()
# The episode's own tool list: it includes ask_user on tasks with a simulated user.
tools = [{"name": t["name"], "description": t["description"], "input_schema": t["parameters"]} for t in ep["tools"]]
messages = [{"role": "user", "content": ep["instruction"]}]
while True:
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=16000,
system=ep["policy"],
tools=tools,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
calls = [b for b in response.content if b.type == "tool_use"]
if response.stop_reason != "tool_use" or not calls:
break
results = []
for call in calls:
obs = requests.post(f"{API}/episodes/{ep['episode_id']}/step",
json={"tool": call.name, "arguments": call.input}).json()
results.append({"type": "tool_result", "tool_use_id": call.id, "content": str(obs)})
messages.append({"role": "user", "content": results})
print(requests.post(f"{API}/episodes/{ep['episode_id']}/submit").json()["pass"])Errors
HTTP errors use one shape: { "error": { "code": "not_found", "message": "…" } }. Codes: invalid_json, invalid_request, unauthorized, not_found, episode_closed, step_limit, rate_limited (with a retry-after header), verifier_unavailable, internal_error.
Rate limits
| Anonymous (per IP) | API key | |
|---|---|---|
| Episodes per hour | 60 | 2,000 |
| Steps per hour | 1,500 | 60,000 |