The PillDrops SDKs.
Everything the HTTP API does, one import away. The Python and JavaScript SDKs and the CLI create isolated sandboxes, run commands and code snippets, move files, host app servers behind public preview URLs, and build custom images — with credentials picked up automatically from your environment. Use the language toggle on any snippet; your choice sticks across the page.
[01] Installation
Both SDKs are dependency-light: the Python package needs only requests,
the JS package has zero runtime dependencies (Node 18+, built-in fetch). The CLI is a
single static binary.
pip install pilldrops
npm install @pilldrops/sdk
# one-line installer (macOS / Linux, arm64 + x64) curl -fsSL https://pilldrops.com/install.sh | sh # or via npm — downloads the same static binary on first run npm install -g @pilldrops/cli
[02] Authentication & configuration
Create an API key in the dashboard (new accounts get free credits — no card). The SDKs and CLI resolve the endpoint and key from the same sources, in this order:
- Explicit arguments —
api_url/api_key(Python),apiUrl/apiKey(JS). - Environment variables —
PILLDROPS_API_URL+PILLDROPS_API_KEY, with the legacyPILLDROPS_*andSANDBOX_*names accepted as fallbacks. - Config file —
~/.pilldrops/config.json, written bypilldrops login. Env vars win over the file, so CI can override a developer login.
export PILLDROPS_API_URL=https://pilldrops.com export PILLDROPS_API_KEY=sf_... # or pass explicitly: Sandbox.create(template="python-3.11", api_url="https://pilldrops.com", api_key="sf_...")
export PILLDROPS_API_URL=https://pilldrops.com export PILLDROPS_API_KEY=sf_... // or pass explicitly: await Sandbox.create({ template: 'python-3.11', apiUrl: 'https://pilldrops.com', apiKey: 'sf_...' })
# browser sign-in (same account as the dashboard) — stores a key in ~/.pilldrops/config.json pilldrops login # headless / CI / agents: pilldrops login --key sf_... # …or just export PILLDROPS_API_KEY and skip login entirely
[03] Quickstart
Create a sandbox, run a command, tear it down. In Python, use the sandbox as a
context manager so it is always destroyed — even if your code throws. In JS, call
kill() in a finally block.
from pilldrops import Sandbox with Sandbox.create(template="python-3.11") as sb: r = sb.run("python3 -c 'print(2+2)'") print(r.stdout) # "4\n" print(r.ok) # True (exit_code == 0) # sandbox destroyed automatically when the block exits
import { Sandbox } from '@pilldrops/sdk' const sb = await Sandbox.create({ template: 'python-3.11' }) try { const r = await sb.run("python3 -c 'print(2+2)'") console.log(r.stdout) // "4\n" console.log(r.ok) // true (exitCode === 0) } finally { await sb.kill() // destroy the sandbox }
pilldrops sandbox create --template python-3.11 # → sb_abc123… pilldrops exec sb_abc123 -- python3 -c 'print(2+2)' # → 4 (exec exits with the remote command's exit code, like ssh) pilldrops sandbox rm sb_abc123
[04] Creating sandboxes
Sandbox.create() boots an isolated machine from a
template or a
custom image and returns once it is running —
typically well under a second. All options:
| Option | Type | Description |
|---|---|---|
| template | str | Optional. Template id, e.g. python-3.11, node-20 — omit it and both SDKs default to python-3.11. List what's available via Templates. |
| image | str | A custom image tag from build_image — overrides template. |
| start | str | Command to launch as the sandbox's app process (e.g. npm run dev). Its output shows up in logs(). |
| ports | list[int] | Ports to publish. The first one becomes the public preview URL (sb.url). |
| env | dict | list | Environment for the sandbox — {"KEY": "VALUE"} or ["KEY=VALUE", …]. |
| vcpu | int | vCPU count. Omit for the template default. |
| mem_mb / memMb | int | Memory in MB. Omit for the template default. |
| storage_mb / storageMb | int | Disk in MB. Omit for the template default. |
| timeout | int | Sandbox TTL in seconds — it is destroyed automatically when this expires. Extend later with set_timeout(). |
| api_url / apiUrl | str | Endpoint override (else resolved per Authentication). |
| api_key / apiKey | str | Key override (else resolved per Authentication). |
sb = Sandbox.create(
template="node-20",
env={"NODE_ENV": "production"},
vcpu=2, mem_mb=2048,
timeout=1800, # auto-destroy after 30 min
)
print(sb.id) # "sb_…"
print(sb.status) # "running"
print(sb.template) # "node-20"
const sb = await Sandbox.create({ template: 'node-20', env: { NODE_ENV: 'production' }, vcpu: 2, memMb: 2048, timeout: 1800, // auto-destroy after 30 min }) console.log(sb.id) // "sb_…" console.log(sb.status) // "running" console.log(sb.template) // "node-20"
[05] Running commands
run() executes a shell command inside the sandbox and returns the full
result — the call your agent loop makes the most. Working directory defaults to the workspace root;
the per-command timeout defaults to 60 seconds.
r = sb.run("pip install requests && python3 app.py", timeout=120) r.stdout # captured stdout (str) r.stderr # captured stderr (str) r.exit_code # int — 0 on success r.truncated # True if output was cut at the size cap r.ok # exit_code == 0 r.check() # raise RuntimeError if it failed (chainable) # run in a specific directory — simplest is cd inside the command: sb.run("cd app && npm test", timeout=300) # cwd works too, but must be an ABSOLUTE path that exists: sb.run("npm test", cwd="/root/app", timeout=300)
const r = await sb.run('pip install requests && python3 app.py', undefined, 120) r.stdout // captured stdout (string) r.stderr // captured stderr (string) r.exitCode // number — 0 on success r.truncated // true if output was cut at the size cap r.ok // exitCode === 0 // run in a specific directory — simplest is cd inside the command: await sb.run('cd app && npm test', undefined, 300) // the 2nd arg is cwd — if set, it must be an ABSOLUTE path that exists
cwd gotcha. A relative or non-existent cwd
fails silently — you get exit_code -1 with empty output, not an error message. Prefer
cd dir && … inside the command, or pass an absolute path.run() waits for the command to finish.
For servers and other processes that never exit, pass a start command at
create time instead — the sandbox supervises it and
captures its output for logs().[06] Running code snippets
The convenience call for AI-generated code: run_code() writes the
snippet to a workspace file and executes it with the right interpreter —
python (default), node, or bash. The result is a normal
ExecResult.
r = sb.run_code(""" import json data = {"answer": sum(range(101))} print(json.dumps(data)) """) print(r.stdout) # {"answer": 5050} sb.run_code("console.log(process.version)", lang="node") sb.run_code("uname -a", lang="bash")
const r = await sb.runCode(` import json data = {"answer": sum(range(101))} print(json.dumps(data)) `) console.log(r.stdout) // {"answer": 5050} await sb.runCode('console.log(process.version)', 'node') await sb.runCode('uname -a', 'bash')
[07] Filesystem
Read and write files, and push or pull whole directories. Relative paths resolve
against the sandbox's working directory — the same place commands run in — so a file you write is
immediately visible to run() at the same relative path.
# single files (str or bytes) sb.files.write("app/config.json", '{"debug": false}') text = sb.files.read("app/config.json") # list a directory → [{name, is_dir, size, modified}, …] for entry in sb.files.list("app"): print(entry["name"], entry["is_dir"]) # whole directories — tar-streamed both ways sb.upload("./my-project", "app") # local dir → sandbox sb.download("app/dist", "./dist") # sandbox dir → local # upload/download work for single files too sb.upload("./main.py", "main.py") sb.download("out.txt", "./out.txt")
// single files (string or Uint8Array) await sb.writeFile('app/config.json', '{"debug": false}') const text = await sb.readFile('app/config.json') // upload a local file or an entire directory (tar-streamed) await sb.upload('./my-project', 'app') // directory listing / bulk download aren't wrapped yet in JS — // use run() or the raw API for those: const ls = await sb.run('ls -la app')
# cp works both directions, scp-style
pilldrops cp ./main.py sb_abc123:/workspace/main.py
pilldrops cp sb_abc123:/workspace/out.txt ./out.txt
[08] Web apps & preview URLs
Give create() a start command and a port, and the sandbox
hosts your app behind a public HTTPS URL — sb.url. It works in an iframe, a new tab, or
a computer-use agent's browser. The URL stays None/undefined until a port
is published.
with Sandbox.create( template="node-20", start="npm run dev", ports=[3000], ) as app: print(app.url) # https://…/preview/sb_…/3000/
const app = await Sandbox.create({ template: 'node-20', start: 'npm run dev', ports: [3000], }) console.log(app.url) // https://…/preview/sb_…/3000/
$PORT. The sandbox injects a PORT
environment variable for your start process. Apps that hardcode a port instead of binding
$PORT (or the port you passed in ports) are the #1 cause of preview-URL
502s.[09] Common workflows
Recipes for the situations that come up most. Rule of thumb for getting a project
in: a one-off run of local changes → upload(); anything already in git → clone from
inside the sandbox; the same project booted repeatedly → bake a
custom image.
Clone a git repo and run it
Usually the best way to bring in a real project: the clone happens datacenter-side at datacenter bandwidth, instead of streaming the repo over your uplink.
with Sandbox.create(template="node-20", timeout=1800) as sb: sb.run("git clone --depth 1 https://github.com/you/repo app", timeout=300).check() sb.run("cd app && npm install", timeout=600).check() r = sb.run("cd app && npm test", timeout=600) print(r.stdout if r.ok else r.stderr)
const sb = await Sandbox.create({ template: 'node-20', timeout: 1800 }) await sb.run('git clone --depth 1 https://github.com/you/repo app', undefined, 300) await sb.run('cd app && npm install', undefined, 600) const r = await sb.run('cd app && npm test', undefined, 600) console.log(r.ok ? r.stdout : r.stderr) await sb.kill()
Private repos
Pass a token through env at create time and use it in the clone URL — it never lands
in your code or shell history.
sb = Sandbox.create(env={"GH_TOKEN": os.environ["GH_TOKEN"]})
sb.run("git clone https://oauth2:${GH_TOKEN}@github.com/you/private-repo app").check()
Upload a local project, run its tests, download the artifacts
For uncommitted local changes. upload() tars the whole directory — upload a clean
checkout, not one with node_modules or build output in it.
with Sandbox.create(template="python-3.11") as sb: sb.upload("./my-project", "app") sb.run("cd app && pip install -r requirements.txt", timeout=600).check() sb.run("cd app && pytest --cov --cov-report=html", timeout=600) sb.download("app/htmlcov", "./htmlcov") # pull the report back
const sb = await Sandbox.create({ template: 'python-3.11' }) await sb.upload('./my-project', 'app') await sb.run('cd app && pip install -r requirements.txt', undefined, 600) const r = await sb.run('cd app && pytest', undefined, 600) // bulk download isn't wrapped in JS yet — read single files: const report = await sb.readFile('app/report.txt') await sb.kill()
Host an app straight from a repo
start is a shell command, so it can clone, install, and serve in one line — and
ports gives you the public preview URL.
app = Sandbox.create(
template="node-20",
start="sh -c 'git clone --depth 1 https://github.com/you/webapp app "
"&& cd app && npm install && PORT=$PORT npm start'",
ports=[3000], timeout=3600,
)
print(app.url) # public HTTPS URL — watch progress with app.logs()
The agent loop: run AI-generated code, feed errors back
A failing snippet returns a result instead of raising, so the retry loop is three lines.
for attempt in range(3): r = sb.run_code(code) if r.ok: break code = llm.fix(code, r.stderr) # hand stderr back to the model
[10] Pause, resume & timeouts
Sandboxes bill per second while running. Pause one to stop compute billing and keep its filesystem; resume it later with state intact. Every sandbox also carries a TTL so forgotten boxes clean themselves up.
sb.pause() # suspend — billing stops, disk kept sb.resume() # wake it up where it left off sb.set_timeout(3600) # replace the remaining TTL: destroy 1 h from now sb.kill() # destroy immediately (idempotent, never raises)
await sb.pause() // suspend — billing stops, disk kept await sb.resume() // wake it up where it left off await sb.setTimeout(3600) // replace the remaining TTL: destroy 1 h from now await sb.kill() // destroy immediately
[11] Logs & metrics
See what's happening inside: logs() returns the captured output of the
sandbox's start process, metrics() returns live CPU / memory usage — the
same data the dashboard graphs. Ad-hoc run() output isn't in the logs — it comes back
on the ExecResult itself.
out = sb.logs() # {"lines": [...], "truncated": False} for line in out["lines"]: print(line) m = sb.metrics() # {"cpu": …, "mem": …, …}
const m = await sb.metrics() // { cpu: …, mem: …, … } // app logs aren't wrapped yet in JS — GET /v1/sandboxes/{id}/logs, // or tail from inside: await sb.run('tail -n 100 /var/log/app.log')
pilldrops logs sb_abc123 -f # follow app logs pilldrops ps sb_abc123 # process list (pid, cmd, cpu, mem) pilldrops metrics sb_abc123 # live CPU / RAM
[12] Templates
Templates are ready-made environments — python-3.11, node-20,
go, code-interpreter, playwright, desktop, and the coding-agent CLIs. List what your deployment can
boot and pass the id as template on create — or pass nothing and get
python-3.11, the default in both SDKs. Some ids are aliases: the created sandbox may
report the canonical id (e.g. python-3.11 shows up as sb.template ==
"code-interpreter").
from pilldrops import Templates for t in Templates().list(): print(t["id"])
import { listTemplates } from '@pilldrops/sdk' const templates = await listTemplates() console.log(templates.map(t => t.id))
pilldrops templates
[13] Custom images
Bring any Dockerfile. build_image() tars a local directory (Dockerfile at
its root), builds it on the fleet, and returns a tag you can launch with
Sandbox.create(image=…). Builds are cached; a failed build raises with the build log.
from pilldrops import Sandbox, build_image img = build_image("./myapp", "myapp") # dir with a Dockerfile → tag with Sandbox.create(image=img) as sb: sb.run("python3 --version").check()
import { Sandbox, buildImage } from '@pilldrops/sdk' const img = await buildImage('./myapp', 'myapp') const sb = await Sandbox.create({ image: img })
pilldrops image build ./myapp -t myapp pilldrops sandbox create --image myapp
[14] Error handling
Two kinds of failure, deliberately kept apart: API errors
(bad key, sandbox gone, quota) raise/throw; a failing command does not —
it returns a normal ExecResult with a non-zero exit code, so your agent loop can read
stderr and retry.
try: sb = Sandbox.create(template="python-3.11") except PermissionError: # 401 — invalid API key ... except RuntimeError as e: # any other API failure (message has status + body) ... r = sb.run("pytest") if not r.ok: # command failed — not an exception print(r.stderr) sb.run("pytest").check() # …or make it raise, subprocess-style
try { const sb = await Sandbox.create({ template: 'python-3.11' }) } catch (err) { // API failure — err.message carries the status + response body } const r = await sb.run('pytest') if (!r.ok) { // command failed — not an exception console.error(r.stderr) }
[15] SDK method reference
The complete surface of both SDKs. Python names are snake_case, JS names
are camelCase; JS methods all return promises.
class Sandbox
| Python | JavaScript | Description |
|---|---|---|
| Sandbox.create(…) | Sandbox.create(opts) | Boot a sandbox — all options under Creating sandboxes. |
| .id · .url · .template · .status | same | Id, public preview URL (once a port is published), template id, lifecycle state. |
| .run(cmd, cwd="", timeout=60) | .run(cmd, cwd?, timeout?) | Execute a shell command → ExecResult. cwd, if set, must be an absolute path. |
| .run_code(code, lang="python") | .runCode(code, lang?) | Write a snippet to the workspace and run it (python | node | bash). |
| .files.write(path, content) | .writeFile(path, content) | Write a file (text or bytes); parent dirs auto-created. |
| .files.read(path) | .readFile(path) | Read a file as text. |
| .files.list(path="/") | — | Directory entries {name, is_dir, size, modified}. (JS: use run('ls…') or the raw API.) |
| .upload(local, remote) | .upload(local, remote) | Upload a file or whole directory (tar-streamed). |
| .download(remote, local) | — | Download a file or whole directory. (JS: raw API.) |
| .pause() / .resume() | .pause() / .resume() | Suspend billing with disk kept / wake with state intact. |
| .set_timeout(seconds) | .setTimeout(seconds) | Replace the remaining TTL. |
| .logs() | — | Captured output of the start process, as {"lines": […], "truncated": bool}. (JS: raw API.) |
| .metrics() | .metrics() | Live CPU / memory usage. |
| .kill() | .kill() | Destroy the sandbox immediately. |
Module-level
| Python | JavaScript | Description |
|---|---|---|
| build_image(dir, tag) | buildImage(dir, tag, opts?) | Build a Dockerfile context on the fleet → launchable tag. |
| Templates().list() | listTemplates(opts?) | Templates this deployment can boot. |
| config() | config() | The resolved {api_url, key} — handy for debugging credential pickup. |
| ExecResult | ExecResult | stdout · stderr · exit_code/exitCode · truncated · ok; Python adds .check(). |
[16] CLI reference
The pilldrops CLI wraps the same API for terminals, CI, and agents.
Install it with curl -fsSL https://pilldrops.com/install.sh | sh or
npm i -g @pilldrops/cli.
# auth pilldrops login # browser sign-in (same account as the dashboard) pilldrops login --key sf_... # headless / CI pilldrops whoami | logout # sandboxes pilldrops templates pilldrops sandbox create --template python-3.11 [--vcpu 2 --mem 1024] pilldrops sandbox ls | get <id> | rm <id> | pause <id> | resume <id> pilldrops exec <id> -- python3 -c 'print(1)' # exits with the remote code pilldrops cp app.py <id>:/workspace/app.py # scp-style, both directions pilldrops logs <id> [-f] pilldrops ps <id> | metrics <id> # images & account pilldrops image build ./myapp -t myapp pilldrops usage # agents & tooling pilldrops ai [--json] # the full agent catalog: commands, JSON contract, workflows pilldrops mcp # run the MCP server over the stored login pilldrops doctor [--json] # self-diagnose auth + connectivity pilldrops upgrade # self-update
- Agent contract — every command supports
--json(exactly one JSON value on stdout). It's a global flag, so put it before the subcommand:pilldrops --json sandbox create. Errors are{"error", "code", "hint"}on stderr; no prompts when stdin isn't a TTY. - Exit codes —
0ok ·1API/network error ·2usage error ·3not authenticated.execpropagates the remote command's exit code, likessh.
/openapi.json. Agent-facing summary:
/llms.txt.