API reference

Base URL: http://localhost:9000

Authentication

All /v1/* routes require your customer API key:

Authorization: Bearer <your-api-key>

Mint a key on your dashboard, or use sandy login. The Python SDK reads ~/.config/sandy/config.toml after login.

CLI

Install: pip install -e ./portal. Credentials stored in ~/.config/sandy/config.toml (mode 600). Override with SANDY_CONFIG_DIR.

CommandDescription
sandy loginSign in; mints an API key and saves config
sandy whoamiShow api_url, auth_url, key prefix
sandy logoutClear saved credentials
sandy keys create [-n NAME]Mint another API key
sandy sandboxes createCreate a sandbox; prints the id
sandy sandboxes listList running sandboxes
sandy sandboxes rm <id>Destroy a sandbox
sandy exec <id> COMMAND...Run a command; exits with the remote exit code
SDK environment variables
VariableDescription
SANDY_API_KEYYour API key — alternative to sandy login
Quick start
pip install sandy

sandy login          # sign in at sandy.decision-labs.com
sandy sandboxes create
sandy sandboxes list
sandy exec <sandbox-id> -- echo hello

Routes

MethodPathDescription
GET/healthLiveness
POST/v1/sandboxesCreate sandbox
GET/v1/sandboxesList running sandboxes
GET/v1/sandboxes/{id}Status
DELETE/v1/sandboxes/{id}Destroy
POST/v1/sandboxes/{id}/execRun command
POST/v1/sandboxes/{id}/files/readRead file
POST/v1/sandboxes/{id}/files/writeWrite file
POST/v1/sandboxes/{id}/files/listList directory
POST/v1/sandboxes/{id}/repl/executePython REPL
GET/v1/sandboxes/{id}/broker/pendingLLM broker — poll
POST/v1/sandboxes/{id}/broker/respondLLM broker — respond
GET/v1/usageUsage totals by event type
GET/v1/billing/statusPlan, limits, and current usage

Create a sandbox

Shell + SDK
Python
curl

List sandboxes

GET /v1/sandboxes
→ {"sandboxes": [{"id": "…", "status": "running", "created_at": "…", "last_activity_at": "…"}]}

Running sandboxes only. last_activity_at updates on exec, files, REPL, and broker calls.

Exec

POST /v1/sandboxes/{id}/exec
{"command": "echo hello", "cwd": ".", "timeout_s": 30}
→ {"exit_code": 0, "stdout": "hello\n", "stderr": ""}
CLI
Python
curl

Files

Paths are relative to /workspace inside the sandbox.

POST .../files/read   {"path": "notes.txt"}              → {"content": "..."}
POST .../files/write  {"path": "notes.txt", "content": "hi"}
POST .../files/list   {"path": "."}                      → {"entries": [...]}

REPL

POST /v1/sandboxes/{id}/repl/execute
{"code": "x = 1 + 1", "inject": {"context": {"key": "val"}}}
→ {"stdout": "", "stderr": "", "locals": {"x": 2}, "final_answer": null}

Stateful dill REPL. Built-ins include rlm_query(prompt) and llm_query(prompt). Use LLMPoller or SandyIsolatedREPL on the host to respond.

LLM broker

Credentials stay on the host. Guest code calls llm_query(prompt); requests queue inside the sandbox. The host polls and injects responses.

# host — Python SDK
from sandy import LLMPoller

with LLMPoller(sandbox, lm=my_lm) as poller:
    poller.run_until_done()
# host — curl
GET  /v1/sandboxes/{id}/broker/pending
POST /v1/sandboxes/{id}/broker/respond
     {"request_id": "…", "response": "…"}

Recursive spawning (RLM)

rlm_query(prompt) inside the REPL spawns a child sandbox, runs the sub-task in isolation, and returns the result. Children can recursively spawn their own children. Works with Docker and Firecracker (forkd) backends. Every call is stamped with provenance metadata.

from sandy import SandyIsolatedREPL

env = SandyIsolatedREPL(api_url=..., api_key=..., spawn_backend="forkd")
result = env.run(code=PARENT_CODE, context={"data": "..."})

tree = env.recorder.get_call_tree()     # hierarchical tree
jsonl = env.recorder.export_jsonl()     # flat JSONL for replay/audit

Each completion in the tree contains: run_id, call_id, parent_call_id, call_index, depth, spawn_kind, checkpoint_id, timestamp.

Working examples in scripts/demo_*_claude.py: recursive summarisation, debate (FOR/AGAINST/JUDGE), 4-specialist code review, N-contestant tournament.

Usage & billing

Sandy meters your sandbox-seconds and LLM queries automatically. View usage on the dashboard or query it via API. Soft caps return 429 when your plan limit is reached.

GET /v1/usage
GET /v1/usage?since=2026-07-01T00:00:00Z
→ {
    "events": [
      {"type": "sandbox_seconds", "total": 142.3, "unit": "seconds"},
      {"type": "llm_query",       "total": 38,    "unit": "calls"}
    ]
  }
GET /v1/usage/breakdown

Tag sandboxes with labels.customer_id to see per-customer costs.

GET /v1/usage/breakdown?customer_id=acme-corp
→ {
    "rows": [
      {"customer_id": "acme-corp", "sandbox_seconds": 45.2,
       "llm_queries": 12, "estimated_cost": 0.21}
    ]
  }
GET /v1/billing/status
GET /v1/billing/status
→ {
    "plan":   "free",
    "limits": {"sandbox_seconds": null, "llm_queries": null},
    "used":   {"sandbox_seconds": 142.3, "llm_queries": 38}
  }

Guides

Each guide walks through a real multi-agent pattern end to end — from the SandyIsolatedREPL setup to reading the call tree. Every example spawns child sandboxes via rlm_query() and returns full provenance.

01 Parallel summarisation

The simplest fan-out pattern: the parent spawns one child per document, each child summarises independently, and the parent merges the results. Wall time ≈ single-child time regardless of how many documents you have.

How it works
  1. Parent REPL calls rlm_query() once per document — all spawn in parallel.
  2. Each child sandbox receives the document in context, summarises it, and sets answer["ready"] = True.
  3. Parent collects all responses and assembles the final brief.
Code
from sandy import SandyIsolatedREPL

PARENT = """
summary_a = rlm_query("Summarise in 2 sentences: " + context["doc_a"])
summary_b = rlm_query("Summarise in 2 sentences: " + context["doc_b"])
answer = {"brief": summary_a + "\\n\\n" + summary_b, "ready": True}
"""

env = SandyIsolatedREPL(api_url=SANDY_URL, api_key=KEY)
result = env.run(code=PARENT, context={"doc_a": DOC_A, "doc_b": DOC_B})
print(result["brief"])

# Inspect call tree
for node in env.recorder.flat():
    print(f"{node.kind:4}  depth={node.depth}  {node.call_id[:8]}")
What you'll see
call tree: 4 completions
  [0] rlm   depth=1  id=64281952  parent=null
  [1] llm   depth=2  id=4fe8a778  parent=64281952
  [2] rlm   depth=1  id=574e2aba  parent=null
  [3] llm   depth=2  id=b741a941  parent=574e2aba

Two rlm nodes (one per child sandbox) each with one llm node (the actual model call inside the child).

02 Debate

Three child sandboxes argue a topic independently — one FOR, one AGAINST, one acting as JUDGE. The judge receives both arguments as context and delivers a verdict. All three spawn in parallel.

Code
PARENT = """
topic = context["topic"]

arg_for     = rlm_query(f"Argue strongly FOR: {topic}. 3 sentences.")
arg_against = rlm_query(f"Argue strongly AGAINST: {topic}. 3 sentences.")

verdict = rlm_query(
    f"You are a judge. Topic: {topic}\\n\\n"
    f"FOR: {arg_for}\\n\\nAGAINST: {arg_against}\\n\\n"
    f"Deliver a balanced verdict."
)
answer = {"for": arg_for, "against": arg_against, "verdict": verdict, "ready": True}
"""

env = SandyIsolatedREPL(api_url=SANDY_URL, api_key=KEY)
result = env.run(code=PARENT, context={"topic": "AI should be open source"})
print(result["verdict"])
Call tree shape
rlm  depth=1  [FOR argument]
  llm  depth=2
rlm  depth=1  [AGAINST argument]
  llm  depth=2
rlm  depth=1  [JUDGE]
  llm  depth=2

Three parallel rlm children, each with one llm call. The judge child runs after the first two complete so it can receive their text as context.

03 Multi-specialist code review

Four specialist reviewers analyse a code snippet independently and in parallel — security, performance, correctness, and style. The parent merges their findings into a single structured report.

Code
PARENT = """
code = context["code"]

security    = rlm_query(f"Security review only. Identify vulnerabilities:\\n{code}")
performance = rlm_query(f"Performance review only. Identify bottlenecks:\\n{code}")
correctness = rlm_query(f"Correctness review only. Find logic bugs:\\n{code}")
style       = rlm_query(f"Style review only. Flag readability issues:\\n{code}")

answer = {
    "security": security, "performance": performance,
    "correctness": correctness, "style": style,
    "ready": True,
}
"""

env = SandyIsolatedREPL(api_url=SANDY_URL, api_key=KEY)
result = env.run(code=PARENT, context={"code": MY_CODE})

for aspect, review in result.items():
    if aspect != "ready":
        print(f"── {aspect.upper()} ──")
        print(review)
Cost tracking by team
# Tag the sandbox so you can query cost per team later
sandbox = Sandbox.create(labels={"team": "platform", "project": "auth-service"})

# Query breakdown
GET /v1/usage/breakdown?customer_id=platform

04 Tournament

N contestants each attempt to solve a problem independently. A judge child receives all submissions and picks the winner. Useful for best-of-N sampling, adversarial red-teaming, or generating diverse solutions.

Code
import textwrap
from sandy import SandyIsolatedREPL

N = 4
PROBLEM = "Write a Python function to find the longest palindrome in a string."

# Build parent code with N contestants
PARENT = textwrap.dedent(f"""
    problem = context["problem"]
    n = {N}

    answers = [
        rlm_query(
            f"You are contestant {{i+1}} of {N}. Solve independently:\\n{{problem}}"
        )
        for i in range(n)
    ]

    block = "\\n\\n".join(f"--- Contestant {{i+1}} ---\\n{{a}}" for i, a in enumerate(answers))
    winner = rlm_query(f"Pick the best solution and explain why:\\n{{block}}")
    answer = {{"submissions": answers, "winner": winner, "ready": True}}
""")

env = SandyIsolatedREPL(api_url=SANDY_URL, api_key=KEY)
result = env.run(code=PARENT, context={"problem": PROBLEM})
print(result["winner"])
Scaling up

Increase N freely — all contestants spawn in parallel. Total wall time stays roughly constant as N grows (bound by single-child latency, not N × latency). The call tree will have N+1 rlm nodes: one per contestant, plus one for the judge.

N=4 → 5 rlm nodes (4 contestants + 1 judge)
N=8 → 9 rlm nodes
wall time ≈ same for N=4 and N=8