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:

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:

OptionTypeDescription
templatestrOptional. 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.
imagestrA custom image tag from build_image — overrides template.
startstrCommand to launch as the sandbox's app process (e.g. npm run dev). Its output shows up in logs().
portslist[int]Ports to publish. The first one becomes the public preview URL (sb.url).
envdict | listEnvironment for the sandbox — {"KEY": "VALUE"} or ["KEY=VALUE", …].
vcpuintvCPU count. Omit for the template default.
mem_mb / memMbintMemory in MB. Omit for the template default.
storage_mb / storageMbintDisk in MB. Omit for the template default.
timeoutintSandbox TTL in seconds — it is destroyed automatically when this expires. Extend later with set_timeout().
api_url / apiUrlstrEndpoint override (else resolved per Authentication).
api_key / apiKeystrKey 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.
Long-running processes. 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/
Bind $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

PythonJavaScriptDescription
Sandbox.create(…)Sandbox.create(opts)Boot a sandbox — all options under Creating sandboxes.
.id · .url · .template · .statussameId, 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

PythonJavaScriptDescription
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.
ExecResultExecResultstdout · 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
Going deeper. Everything here is a thin wrapper over the REST API — the interactive API reference lets you run every endpoint live in the browser, and the machine-readable spec lives at /openapi.json. Agent-facing summary: /llms.txt.