AI Agents

Use Spunto workers as isolated sandboxes for AI coding agents — full API control, zero setup overhead.

Spunto workers are ideal environments for AI coding agents. Each worker is an isolated Docker container with a full development stack — code execution, git, terminal access — exposed via a clean REST API.

Tip

If your agent speaks MCP, you can skip the API entirely: point it at Spunto's MCP server and it gets the same capabilities as tools it can call directly.

  • Isolation — each agent run gets a fresh, contained environment; no host system access
  • Pre-configured — repos are cloned, credentials are set, tools are installed at spawn
  • Ephemeral — delete the worker when done; no cleanup needed
  • API-driven — spawn, interact, read output, and destroy entirely via HTTP

1. POST /api/orgs/{orgId}/projects/{projectId}/workers spawn worker
2. Poll GET /api/orgs/{orgId}/projects/{projectId}/workers/{workerId} wait for "ready"
3. Use the worker (terminal, git, file system via code-server)
4. DELETE /api/orgs/{orgId}/projects/{projectId}/workers/{workerId} destroy

Create an API key scoped to the org the agent should operate in — from Account Settings, pick workers:read + workers:write at minimum (add workers:exec if the agent needs an interactive terminal, and projects:read/projects:write if it also manages projects). For an agent deployed long-term outside any one person's account, create an org-owned (service account) key instead of a personal one — it keeps working if the creator leaves the org.

curl -H "Authorization: Bearer spk_..." \
  https://spunto.net/api/orgs/{orgId}/projects

curl -X POST \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  https://spunto.net/api/orgs/{orgId}/projects/{projectId}/workers

Response:

{
  "id": "wkr_abc123",
  "state": "spawning",
  "setupStatus": { "phase": "initializing", ... }
}

Poll the worker endpoint every 2 seconds until state === "ready":

curl -H "Authorization: Bearer <token>" \
  https://spunto.net/api/orgs/{orgId}/projects/{projectId}/workers/wkr_abc123

The setupStatus.phase field tracks each step: cloningfeatureslifecycleready.

The simplest way to run something in a worker is one HTTP call:

curl -X POST \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{ "command": "npm test" }' \
  https://spunto.net/api/orgs/{orgId}/projects/{projectId}/workers/{workerId}/exec

It waits for the command and answers with its exit code and output:

{
  "id": "kx91v...",
  "status": "succeeded",
  "exitCode": 0,
  "stdout": "…",
  "stderr": "",
  "truncated": false
}

The command runs as the workspace user, through bash -l, in /workspace — so the worker's PATH, tool versions and injected secrets all apply. Pass cwd to run somewhere else. Requires the workers:exec scope.

For anything past a minute — a build, a test suite, a full agent run — add background: true. The call returns immediately with a command id, and the command keeps running inside the worker, so it survives your client disconnecting and even an API redeploy.

# start it
ID=$(curl -s -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{ "command": "npm run build", "background": true }' \
  https://spunto.net/api/orgs/{orgId}/projects/{projectId}/workers/{workerId}/exec | jq -r .id)
 
# poll it — output is visible while it runs, not only at the end
curl -s -H "Authorization: Bearer <token>" \
  https://spunto.net/api/orgs/{orgId}/projects/{projectId}/workers/{workerId}/commands/$ID

status goes from running to succeeded, failed, timeout, or canceled (POST .../commands/{id}/cancel stops it and everything it spawned). GET .../commands lists the history for a worker. Deadlines default to 60 s waiting / 1 h background, and are enforced inside the container — an expired command is really killed.

Tip

Using an MCP client instead of raw HTTP? These are the run_command, get_command and cancel_command tools — see the MCP server reference.

stdout and stderr are the last 64 KiB of each stream (truncated tells you when there was more). For a full log, redirect to a file in the workspace and fetch it afterwards.

When you need a real interactive shell rather than one-shot execution — feeding input to a program, or attaching to a session a human can watch — the worker terminal is a full PTY exposed over WebSocket. It accepts the same Authorization: Bearer <api key> header as every REST route, so no browser session is needed.

wss://spunto.net/api/workers/{workerId}/terminal?mode=terminal&cols=220&rows=50
Query paramDefaultDescription
modeterminal (interactive PTY) or logs (stdout/stderr stream, read-only)
cols / rows80 / 24PTY dimensions — set to a wide value (e.g. 220 × 50) to avoid line-wrapping in agent output
sessionmaintmux session name — reuse the same name to reconnect to a running session

Auth — either the browser session cookie (existing behavior) or an Authorization: Bearer spk_... header with the workers:exec scope (or *). A missing/invalid token returns 401; a valid token with the wrong scope returns 403.

Protocol:

  1. Wait for { "type": "ready" } — the PTY is attached
  2. Send { "type": "input", "data": "<base64>" } — raw keystrokes, including \r to submit a line
  3. Receive { "type": "output", "data": "<base64>" } — raw PTY output (ANSI included)
  4. Send { "type": "resize", "cols": N, "rows": M } to resize the PTY

The WebSocket closes when the shell exits. Reconnecting with the same ?session=<name> reattaches the tmux session — the shell and its history survive a disconnect. This is the right way to run a long-lived agent command (e.g. claude code --yes) without needing to keep the connection open for the full duration.

Node.js example:

import WebSocket from "ws"
import { Buffer } from "node:buffer"
 
const ws = new WebSocket(
  `wss://spunto.net/api/workers/${workerId}/terminal?mode=terminal&cols=220&rows=50`,
  { headers: { Authorization: `Bearer ${apiKey}` } }
)
 
// Wait for ready, then send a command
ws.once("message", (raw) => {
  const msg = JSON.parse(raw.toString())
  if (msg.type !== "ready") return
 
  ws.send(JSON.stringify({
    type: "input",
    data: Buffer.from("claude 'implement feature X' --yes\r").toString("base64"),
  }))
})
 
// Stream output to stdout
ws.on("message", (raw) => {
  const msg = JSON.parse(raw.toString())
  if (msg.type === "output") {
    process.stdout.write(Buffer.from(msg.data, "base64"))
  }
})

Tip

For a long-running agent command, send the command in one shot and close the WS immediately — the tmux session keeps the process alive. Reconnect later with the same ?session= name to check on it or read the scrollback.

curl -H "Authorization: Bearer <token>" \
  "https://spunto.net/api/orgs/{orgId}/projects/{projectId}/workers/{workerId}/logs?tail=100"

Returns plain text stdout/stderr from the container.

Use User Secrets to inject credentials (API keys, tokens) as environment variables into every worker, without storing them in the project config:

curl -X POST \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "name": "ANTHROPIC_API_KEY", "value": "sk-ant-..." }' \
  https://spunto.net/api/users/me/secrets

The secret is encrypted at rest and injected at spawn time. Values are never returned by the API.

A minimal setup for running Claude Code in an isolated Spunto worker:

  1. Create a project with your codebase repo and ANTHROPIC_API_KEY in User Secrets
  2. Set postCreateCommand: "npm install -g @anthropic-ai/claude-code"
  3. Spawn a worker via API
  4. Wait for state === "ready"
  5. Connect via WebSocket terminal and run claude "implement feature X"
  6. Read output, commit changes, delete worker

Tip

Workers have GITHUB_TOKEN pre-injected. Claude Code (and gh CLI) will pick it up automatically for PR creation, issue reading, etc.

After an agent run, check what changed:

curl -H "Authorization: Bearer <token>" \
  https://spunto.net/api/orgs/{orgId}/projects/{projectId}/workers/{workerId}/git-status

Returns per-repo branch and modified file count — useful for CI pipelines that spawn a worker, run an agent, and then open a PR.