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.
| Command | Description |
|---|---|
sandy login | Sign in; mints an API key and saves config |
sandy whoami | Show api_url, auth_url, key prefix |
sandy logout | Clear saved credentials |
sandy keys create [-n NAME] | Mint another API key |
sandy sandboxes create | Create a sandbox; prints the id |
sandy sandboxes list | List running sandboxes |
sandy sandboxes rm <id> | Destroy a sandbox |
sandy exec <id> COMMAND... | Run a command; exits with the remote exit code |
| Variable | Description |
|---|---|
SANDY_API_KEY | Your API key — alternative to sandy login |
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
| Method | Path | Description |
|---|---|---|
GET | /health | Liveness |
POST | /v1/sandboxes | Create sandbox |
GET | /v1/sandboxes | List running sandboxes |
GET | /v1/sandboxes/{id} | Status |
DELETE | /v1/sandboxes/{id} | Destroy |
POST | /v1/sandboxes/{id}/exec | Run command |
POST | /v1/sandboxes/{id}/files/read | Read file |
POST | /v1/sandboxes/{id}/files/write | Write file |
POST | /v1/sandboxes/{id}/files/list | List directory |
POST | /v1/sandboxes/{id}/repl/execute | Python REPL |
GET | /v1/sandboxes/{id}/broker/pending | LLM broker — poll |
POST | /v1/sandboxes/{id}/broker/respond | LLM broker — respond |
GET | /v1/usage | Usage totals by event type |
GET | /v1/billing/status | Plan, limits, and current usage |
Create a sandbox
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": ""}
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?since=2026-07-01T00:00:00Z
→ {
"events": [
{"type": "sandbox_seconds", "total": 142.3, "unit": "seconds"},
{"type": "llm_query", "total": 38, "unit": "calls"}
]
}
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
→ {
"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.
- Parent REPL calls
rlm_query()once per document — all spawn in parallel. - Each child sandbox receives the document in
context, summarises it, and setsanswer["ready"] = True. - Parent collects all responses and assembles the final brief.
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]}")
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.
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"])
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.
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)
# 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.
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"])
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