> ## Documentation Index
> Fetch the complete documentation index at: https://docs.boat.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Platform on Boat

> Run your users' agents, previews, and workspaces on Boat: architectures, costs, and provisioning patterns.

This guide is for builders of Lovable-like, Devin-like, or agent-platform products, where your product spawns a sandbox for each of your own users.

What such a platform ultimately needs:

* persistent files and context per user or project
* an agent that works inside the workspace and can debug anything (the sandbox ships five, see [Integrated agents](/integrated-agents))
* a private preview URL of what the user is building
* controlled sandbox cost per user

In all cases, you run your own backend and database, and drive Boat through the [API](/api/v1) or [SDKs](/sdks/overview).

## The two rules

1. **Always create user sandboxes with `--no-env`** (`noEnv: true`). A no-env sandbox receives none of your account's secrets or credentials and cannot act on your account or other sandboxes. Pass what the sandbox does need explicitly with `--env`. See [No-env sandboxes](/environments#safe-for-third-parties).
2. **Tag each sandbox** with per-sandbox environment variables so your backend can identify it.

<Tip>
  Instead of remembering `--no-env` on every call, mark an environment safe for third parties once and point your user sandboxes at it. The same protection then applies to every sandbox that uses it, and it survives forks and resumes without your backend passing a flag.

  ```bash theme={null}
  boat env new users
  boat env set users --safe-for-third-parties true
  boat new --environment users --env TENANT_ID=acme
  ```

  See [Environments](/environments).
</Tip>

<CodeGroup>
  ```bash CLI theme={null}
  boat new --no-env --no-auto-stop --env TENANT_ID=acme
  ```

  ```bash curl theme={null}
  curl -sS -X POST "$BOAT_API_BASE/sandboxes" \
    -H "Authorization: Bearer $BOAT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"ttlSeconds":null,"noEnv":true,"env":{"TENANT_ID":"acme"}}'
  ```

  ```ts TypeScript theme={null}
  await sandbox.create({
    createSandboxRequest: { ttlSeconds: null, noEnv: true, env: { TENANT_ID: "acme" } },
  });
  ```

  ```python Python theme={null}
  sandbox.create(CreateSandboxRequest(ttl_seconds=None, no_env=True, env={"TENANT_ID": "acme"}))
  ```
</CodeGroup>

Wait for the sandbox to reach `ready` or `idle` before running commands in it. This is enforced: a command sent while the sandbox is still provisioning is refused with a retryable 409 `boat_starting` error (or `machine_not_running` even earlier in provisioning), so wait for `ready` instead of racing startup. Commands sent too early would also run before your `env` is applied.

## Pattern 1: one always-on sandbox per project

Simplest, perfectly isolated. Spawn a sandbox per user project with `--no-auto-stop`. Clone or copy the user's code in, run their dev server, and expose it:

<CodeGroup>
  ```bash CLI theme={null}
  boat ssh <id> "cd ~/project && npm run dev -- --host 0.0.0.0 --port 3000 &"
  boat ssh <id> "host 3000 --private"
  # prints https://<subdomain>-3000.on.boat.dev?_token=...
  ```

  ```bash curl theme={null}
  curl -sS -X POST "$BOAT_API_BASE/sandboxes/$BOAT_ID/commands" \
    -H "Authorization: Bearer $BOAT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"command":"npm run dev -- --host 0.0.0.0 --port 3000 &","cwd":"project"}'

  curl -sS -X POST "$BOAT_API_BASE/sandboxes/$BOAT_ID/commands" \
    -H "Authorization: Bearer $BOAT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"command":"host 3000 --private"}'
  ```

  ```ts TypeScript theme={null}
  await sandbox.command({
    sandboxId,
    commandRequest: { command: "npm run dev -- --host 0.0.0.0 --port 3000 &", cwd: "project" },
  });
  const hosted = await sandbox.command({ sandboxId, commandRequest: { command: "host 3000 --private" } });
  console.log(hosted.stdout); // https://<subdomain>-3000.on.boat.dev?_token=...
  ```

  ```python Python theme={null}
  sandbox.command(sandbox_id, CommandRequest(
      command="npm run dev -- --host 0.0.0.0 --port 3000 &",
      cwd="project",
  ))
  hosted = sandbox.command(sandbox_id, CommandRequest(command="host 3000 --private"))
  print(hosted.stdout)  # https://<subdomain>-3000.on.boat.dev?_token=...
  ```
</CodeGroup>

The `_token` URL is private to whoever you give it to. The preview stays up as long as the sandbox runs.

Cost: a `default` sandbox running 24/7 is about \$26/month (\$0.00001 per second); a `large` sandbox is double that. Use this when the project has real traffic or the user pays you enough to cover it.

## Pattern 2: one always-on sandbox per user

Like pattern 1, but one sandbox holds all of a user's projects, each in its own folder on its own port. A small daemon you install in the sandbox multiplexes agent sessions. Works well up to 5 to 10 fullstack projects per user.

Cost: about \$26/month per user instead of per project. Watch isolation: projects of the same user share a machine.

## Pattern 3: stop and resume around usage

Cheapest, isolated, and what most platforms end up with. The sandbox only runs while the user is actively working:

1. User sends a message. If their sandbox is stopped, `boat resume` it (a few seconds), relaunch your daemon and the preview in parallel, and send the message to the agent.
2. When the agent finishes, wait a short countdown, then `boat stop`. Stop snapshots the filesystem and pauses billing.

Files, installed packages, and enabled systemd services survive stop and resume. Hand-run processes do not; your daemon restarts them, or [run it as an always-on service](/long-running-tasks#run-an-always-on-service). See [Snapshots](/snapshots).

### When the user is done for good

Stop is a pause; **delete** is the end. `DELETE /sandboxes/{sandboxId}` (`boat delete`, or the `⋯` menu in the dashboard) force-stops the sandbox and permanently deletes the snapshots only it uses. There is no undo and no resume afterwards, so wire it to an explicit "delete my project" action, never to an idle timeout: stopping is what you want when the user might come back. Snapshot data a fork, a resume or a named template still reads is kept. See [Snapshots](/snapshots#deleting-a-sandboxs-snapshots).

### When a stop is refused

Stopping saves the sandbox's disk first. If that save is failing, we **refuse the stop** and leave the sandbox running rather than throw away the user's work. **You are not billed for that time**: the meter pauses on its own from the first failed attempt, so a stuck sandbox never shows up on your invoice and there is nothing to reclaim. See [When a stop is refused](/snapshots#when-a-stop-is-refused).

Your backend should treat a refused stop as retryable rather than fatal; we retry too, and email the sandbox owner.

If you would rather stop it now and accept losing everything written since the last successful snapshot, pass `force`. It is irreversible, so offer it only after a stop has already failed, and check the last snapshot time first so you know what is being given up.

<CodeGroup>
  ```bash CLI theme={null}
  boat stop bx_23456789 --force
  ```

  ```bash curl theme={null}
  curl -sS -X POST "https://boat.dev/api/v1/sandboxes/bx_23456789/stop" \
    -H "Authorization: Bearer $BOAT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"force":true}'
  ```

  ```ts TypeScript theme={null}
  await sandbox.stop({ sandboxId: "bx_23456789", stopRequest: { force: true } });
  ```

  ```python Python theme={null}
  sandbox.stop(sandbox_id="bx_23456789", stop_request=StopRequest(force=True))
  ```
</CodeGroup>

Cost: \$1 buys about 27 hours of `default` machine time, half that on `large`. A typical user costs \$1 to \$5 per month, a power user \$10 to \$20.

To pass that cost on, read each sandbox's own meter for the billing period with `GET /sandboxes/{sandboxId}/usage` (`boat usage <id>`). It works on running and stopped sandboxes and counts exactly what your balance was charged for that sandbox. See [Per-sandbox usage](/billing#per-sandbox-usage).

Two refinements:

* **Zero preview downtime**: publish successful builds to a static host or CDN. The sandbox is only for the agent and dev preview; production traffic never depends on a sandbox being up.
* **Zero agent latency**: run a tool-less copy of the agent on your own server that answers immediately and stalls while the sandbox resumes. See [optibox](https://github.com/ariana-dot-dev/optibox) for a working implementation.

## Use the integrated agents

Before writing an agent loop, look at what the sandbox already runs. `boat prompt` drives five coding agents (Claude Code, Codex, pi, OpenCode, Prime Agent) with memory, parallel conversations, streaming events, and per-prompt model choice built in. For most platforms it replaces the daemon below. How it works, and every seam to customize it, is on [Integrated agents](/integrated-agents). The tips that matter when you build a product on it:

* **Your user's key, not yours.** Create the sandbox `--no-env` and pass the user's key as a per-sandbox variable: `-e ANTHROPIC_API_KEY=…`, `-e OPENAI_API_KEY=…`. Every harness reads it; nothing of your account is on the sandbox. Keys are per sandbox, so users whose keys must stay apart get their own sandbox (patterns 1 and 3), while a sandbox you pay for yourself can be shared across users through conversations (pattern 2).
* **One conversation per user session.** `POST /prompt` with `new: true` returns a `conversationId`; store it next to the session and pass it back as `conversationId` on every later message. Conversations run in parallel on one sandbox, each with its own memory, and survive stop and resume, so pattern 3 works unchanged: resume the sandbox, prompt the same conversation.
* **Agent and model picker.** A selector in your UI maps directly onto the `provider` and `model` fields of `POST /prompt`. Switching mid-conversation keeps the history, so users can change their mind per message.
* **Stream to your UI.** `GET /events` with a cursor gives structured prompt, response, and tool-call events, each tagged with its `conversationId`. Filter with `conversation` to show one user only their own session.
* **A stop button.** `POST /interrupt` with `conversation` stops one user's turn and leaves the others running.
* **House rules, tools, MCP servers.** The agents read ordinary config files from the sandbox home (`AGENTS.md`, `CLAUDE.md`, MCP registrations, skills). Put yours in the [template](/snapshots#template-sandboxes) once and every fork starts with them; per-user rules go in the prompt or in per-user sandboxes. See [Customize the harness](/integrated-agents#customize-the-harness).
* **Attachments.** `boat prompt --attach` puts the user's files under `~/attachments` on the sandbox and sends images inline to vision models. From a backend, write the file with the [file endpoint](/api/reference/agent/write-sandbox-file) and name its path in the prompt.

## Bring your own harness: the daemon pattern

You do not have to use the built-in agents. If your harness lives in your own infrastructure, or you want full control over the agent loop, put a small HTTP daemon in the sandbox and talk to it directly:

1. **Install it**: `boat scp` the binary in, or bake it into your [template](/snapshots#template-sandboxes).
2. **Keep it alive**: run it as an [always-on systemd service](/long-running-tasks#run-an-always-on-service) so it survives stop, resume, and fork.
3. **Expose it**: `host <port> --private` gives it a stable HTTPS URL gated by a `_token`; treat that token as the daemon's credential and store it in your backend.
4. **Drive it** from your backend over plain HTTPS: your harness sends work, the daemon runs it with full machine access and streams results back.

You can also skip the daemon entirely: [`POST /sandboxes/{sandboxId}/commands`](/api/reference/agent/execute-sandbox-command) runs any command in the sandbox, file endpoints [read](/api/reference/agent/read-sandbox-file) and [write](/api/reference/agent/write-sandbox-file) data, and first-class endpoints cover lifecycle, prompts, events, desktop, and snapshots. In-sandbox tools such as `host` are used by running their commands through that same endpoint. A daemon earns its place when you need streaming, concurrency, or lower latency than one-shot commands give you.

## Provision from a template

Never install your stack on a fresh sandbox per user. Build it once, then fork:

<CodeGroup>
  ```bash CLI theme={null}
  boat new                            # install runtimes, deps, your daemon
  boat stop <template-id>             # its snapshot is now your template
  boat fork <template-id> --no-env    # per user, a few seconds
  ```

  ```bash curl theme={null}
  curl -sS -X POST "$BOAT_API_BASE/sandboxes/<template-id>/fork" \
    -H "Authorization: Bearer $BOAT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"noEnv":true,"env":{"TENANT_ID":"acme"}}'
  ```

  ```ts TypeScript theme={null}
  const forked = await sandbox.fork({
    sandboxId: templateId,
    forkRequest: { noEnv: true, env: { TENANT_ID: "acme" } },
  });
  ```

  ```python Python theme={null}
  forked = sandbox.fork(template_id, ForkRequest(no_env=True, env={"TENANT_ID": "acme"}))
  ```
</CodeGroup>

Per-fork `env` replaces the per-sandbox variables the fork would inherit from the source, in the CLI (`boat fork <id> -e KEY=V`), the API, and the SDKs alike.

Forks inherit the whole filesystem and are usable in seconds at roughly constant cost, whatever the template holds. To update the template: resume it, change it, stop it again. Forks always take the latest snapshot. See [Template Sandboxes](/snapshots#template-sandboxes).

## Sizing

Concurrent sandboxes track daily active users, not signups. A rough heuristic from platforms running on Boat: 10,000 signups is about 100 active users on an average day, so 10 to 100 concurrent sandboxes, with a launch-day peak several times higher.

Size against two separate numbers. Your plan's concurrent-sandbox cap bounds how many sandboxes exist at once, and your machine start limits bound how fast you can bring them up. Creating, forking and resuming each count as one start, so pattern 3 spends starts every time a user comes back, not only on signup. See [Machine starts](/billing#machine-starts).

## Related

* [Integrated agents](/integrated-agents)
* [Environments](/environments)
* [Billing & Limits](/billing)
* [Snapshots](/snapshots)
* [Hosting](/hosting)
* [FAQ](/faq)
