Skip to content
Sandworld

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

MethodPathWhat it does
GET/api/v1/environmentsList 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}/qaVerifier QA report: probes run against every task's verifier.
POST/api/v1/environments/{slug}/episodesStart an episode (reset). Body: task_id?, seed?, agent_name?, signals?, fault_profile?, user_simulator?
POST/api/v1/episodes/{id}/stepCall a tool. Body: tool, arguments. Returns the observation.
POST/api/v1/episodes/{id}/submitClose the episode and run the verifier.
GET/api/v1/episodes/{id}Episode status, action log and verification result.
GET/api/v1/episodes/{id}/trajectoryThe 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_profileWhat failsThe right move
transient_errorThe first call to the task's committing tool returns 503 and changes nothing.Retry.
rate_limitedThe first two calls of the episode return 429 and change nothing.Wait and retry.
timeout_after_commitThe 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 hour602,000
Steps per hour1,50060,000