# froog docs (full text)

> froog gives every agent its own Linux VM: full Ubuntu 24.04 or 26.04, Debian 13 or Omarchy (Arch Linux) machines with SSH, Docker, public HTTPS URLs for any port and a desktop, driven by the `froog` CLI or the HTTP API at https://api.froogcorp.com/v1. This file is generated from https://froogcorp.com/docs/ (the index is https://froogcorp.com/llms.txt).

## Install

```sh
curl -fsSL https://froogcorp.com/install | sh
```

The installer downloads the CLI for your platform, checks its SHA-256 and puts it in `~/.local/bin`. Set `FROOG_BIN_DIR` to install somewhere else, or `FROOG_VERSION=v0.1.0` to pin a version. On Windows 10 or later, in PowerShell: `irm https://froogcorp.com/install.ps1 | iex` (installs `froog.exe` to `%LOCALAPPDATA%\froog\bin` and adds it to your PATH, and `froog ssh` and `froog scp` use Windows' built-in OpenSSH client). WSL works too, with the command above.

## Quickstart

Anyone can sign up: sign in with Google or an email code. No card is needed to sign up. You add one only when you start your first sandbox, which starts a 7-day free trial. Nothing is charged until it ends.

```sh
# opens the dashboard to approve this computer; also registers ~/.ssh/id_ed25519.pub
$ froog login
# or a guided first run: sign in, SSH key, agent keys, a first sandbox, a first prompt
$ froog onboard

# choose a plan: the first opens Stripe Checkout and starts the 7-day free trial
$ froog billing plan 20

# a running VM with everything installed, then a shell
$ froog new my-box
$ froog ssh my-box

# run commands without SSH, from scripts or agents
$ froog exec my-box -- 'git clone https://github.com/you/app && cd app && make test'

# or in one go: a fresh sandbox with this folder, the command, the cost, then it's deleted
$ froog run -- make test

# snapshot the disk and stop billing; froog start my-box resumes
$ froog stop my-box
```

`froog onboard` asks only for what your account still needs: it skips signing in on a computer that's signed in, the plan when you have one, the SSH key when one is registered, the agent keys when `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` or another agent's key is in your [environment](https://froogcorp.com/docs/env/) (it offers to copy one from your shell), and the first sandbox when you have one. Run it again any time. `--yes` takes every default answer, also without a terminal. `froog dashboard` opens the dashboard already signed in.

`froog new` returns when the sandbox accepts SSH, about 20 seconds after you run it, with every tool below already installed. `start`, `fork` and `restore` wait the same way, and the API reports `running` only from that point.

## Sandboxes

Each sandbox is a full VM with its own kernel, reached over SSH (`froog ssh` works out the address and port). You log in as `user` with your SSH keys, and passwordless `sudo` gives you root. Password login is disabled. Your home directory is `/home/user`. The SSH host key is generated by froog and pinned by the CLI, so `froog ssh` and `froog scp` verify the machine they connect to.

### Sizes

| Size | vCPU | Memory | Disk | Per hour |
| --- | --- | --- | --- | --- |
| `small` | 2 | 4 GB | 51 GB | $0.0137 ($10 a full month) |
| `default` (the default) | 4 | 8 GB | 51 GB | $0.0274 ($20 a full month) |
| `large` | 8 | 16 GB | 111 GB | $0.0685 ($50 a full month) |
| `xlarge` | 16 | 32 GB | 251 GB | $0.137 ($100 a full month), coming soon |

`xlarge` is coming soon: starting one answers `403 xlarge_coming_soon` (email hello@froog.tech to try it early). Once it opens, it comes with the $100 plan or higher. The free trial has `small` and `default` only. `froog limits` lists the sizes you can start now.

vCPUs are shared. The disk includes the OS and installed tools. Pick a size with `froog new NAME --size large`, and change it later with `froog start NAME --size …` (a stopped VM).

### What's installed

Choose the operating system when you create a sandbox: **Ubuntu 24.04 LTS** (the default), **Ubuntu 26.04 LTS** or **Debian 13** (trixie). Pick it in the dashboard, or run `froog new NAME --os ubuntu-26.04` or `--os debian`. Over the API, send `"os": "debian-13"` (`GET /v1/images` lists the choices). Forks and restores keep the OS of their source. All come with the same tools, so everything below is ready the moment a sandbox boots. Install anything else with `sudo apt-get install` (`sudo pacman -S` on Omarchy), `npm i -g`, `pip`, `uv` or `cargo`. It stays on the disk across stop, start and fork.

**Omarchy.** `froog new NAME --os omarchy` (API: `"os": "omarchy"`) starts [Omarchy](https://omarchy.org), DHH's Arch Linux setup with the Hyprland tiling desktop, built by the same steps its ISO installer runs, on a 1920x1080 screen. The desktop is already running when the sandbox boots, logged in as `user`, and never locks. `froog desktop` shows it, and the agents' `computer` tools drive it (with Wayland tools underneath). Omarchy's keys work as on a laptop: Super+Return for a terminal, Super+Space for its menu, Super+K for the cheat sheet. `--theme dark|light` picks Tokyo Night or Catppuccin Latte, and `~/.config/froog/wallpaper.png` sets the background. Themes and backgrounds you pick in Omarchy's own menu stay. The browser is Chromium. Omarchy's firewall is on, with SSH allowed. Software rendering (no GPU), so animations are off and video is slow.

| Languages | Node.js 24 (npm, pnpm), Bun, Deno, Python 3.12 (pip, venv, pipx, uv), Go, Rust stable (cargo, rustfmt, clippy), Java 21 (Maven), Ruby, PHP (Composer) |
| --- | --- |
| Build tools | GCC 13, Clang 18, CMake, Ninja, make, pkg-config, autotools, gdb, common `-dev` libraries |
| Containers | Docker with BuildKit, Buildx and Compose, and `user` runs `docker` without sudo |
| Virtualization | `/dev/kvm` (nested virtualization): run QEMU/KVM, Firecracker or the Android emulator inside the sandbox, and `user` is in the `kvm` group |
| Coding agents | Claude Code (`claude`), Codex (`codex`), OpenCode (`opencode`), Gemini CLI (`gemini`), Amp (`amp`), Cursor CLI (`agent`, `cursor-agent` on older releases), pi (`pi`), the newest release of each when the image was built |
| Browser and desktop | Google Chrome, Xvfb, XFCE for `froog desktop` (a 60 fps H.264 stream, or noVNC), xdotool, wmctrl, ffmpeg, `froog-record-desktop` |
| Everyday tools | git, git-lfs, GitHub CLI (`gh`), ripgrep, fd, jq, tmux, htop, vim, nano, tree, sqlite3, rsync, curl, wget, dig, netcat, strace, lsof |

`cat /etc/froog-image` in a sandbox lists the exact versions.

### Auto-stop

Every sandbox has an auto-stop timer. The default is **1 hour**. The longest is 30 days, or no auto-stop at all. The timer counts from the start (create, start, or a warm resume), not from your last activity: a sandbox with a 1-hour timer stops an hour after it starts, even in the middle of your own commands, an SSH session or traffic to its URLs. Each stop snapshots the disk first, so nothing is lost. A running agent prompt is the exception: when the deadline comes while a `froog prompt` runs, it moves one timer length later, again until no prompt runs.

```sh
$ froog new box --ttl 4h               # stops 4 hours after each start (at most 30d)
$ froog new box --no-auto-stop         # never: running time is billed per second, so stop it yourself
$ froog extend box --hours 12          # stop 12 hours from now; later starts get 12 hours too
$ froog extend box --no-auto-stop
$ froog info box                       # auto-stop: at 18:02 (in 3h 59m; 4h after each start)
```

API: `ttl_secs` on create, start and fork (seconds, `0` = no auto-stop, absent or `null` = 1 hour), `PATCH /sandboxes/{name} {ttl_secs}` to change it (a running sandbox then stops that long from now), and the sandbox's `auto_stop_at` says when. During the [free trial](https://froogcorp.com/docs/pricing/) the timer is required, 2 hours at most (and by default).

### Hard lifetime, auto-delete and a deadline

For CI and evals, where a forgotten sandbox shouldn't run or linger:

```sh
$ froog new ci-1 --max-lifetime 2h --delete-after 1d
$ froog extend ci-1 --max-lifetime 4h          # 0 removes it
$ froog extend ci-1 --delete-after 0           # never delete it on its own
$ froog new eval-7 --expires 3h               # deleted 3 hours after it's created, whatever it's doing
$ froog extend eval-7 --expires 1h            # 1 hour from now instead; 0 removes the deadline
```

`--max-lifetime` (API `max_lifetime_secs`) stops the sandbox that long after each start, even while it's busy, snapshotting its disk as any stop does. It counts again from every start. `--delete-after` (API `delete_after_secs`) deletes a sandbox once it has stayed stopped that long, however it was stopped. Starting it again cancels the countdown. This is how Daytona's auto-delete interval and Freestyle's `autoDeleteSeconds` work. Deleting removes its automatic snapshots. Saved snapshots stay. `froog info` and the API's `stop_at` and `delete_at` say when. Both are checked every minute, so they may act up to a minute late. Limits: 30 days for the lifetime, 365 days for auto-delete. Forks get their source's settings. They work alongside the [auto-stop timer](https://froogcorp.com/docs/sandboxes/#auto-stop) (`--ttl`). The lifetime is a hard cap: a running prompt doesn't move it, and `froog extend --hours` doesn't either.

`--expires` (API `expires_in_secs` on create) is a deadline from creation, like Blaxel's `ttl` or Freestyle's `ttlSeconds`: at that time the sandbox is deleted, running or stopped, busy or not. Deleting removes its automatic snapshots. Saved snapshots stay. 60 seconds to 365 days. `PATCH {expires_in_secs}` (`froog extend --expires`) sets it again from now, and `0` removes it. `froog info` and the API's `expires_at` say when. It's checked every minute. A sandbox in the middle of an operation (creating, starting, stopping) goes at the next check after it. Forks don't inherit it. Each such delete is in the [audit log](https://froogcorp.com/docs/audit/) as `sandbox.expire`, by `system`.

### Auto-start

With auto-start on, a stopped sandbox starts when something needs it: a request to one of its [public URLs](https://froogcorp.com/docs/urls/), or an exec, file, background command, prompt or [wait](https://froogcorp.com/docs/urls/#wait-port) call over the API. Together with the [auto-stop timer](https://froogcorp.com/docs/sandboxes/#auto-stop), a preview or an agent's sandbox stops on its own and costs nothing while nobody uses it.

```sh
$ froog new preview --ttl 15m --auto-start --host 3000:public
$ froog extend preview --no-auto-start           # off again (--auto-start turns it on)
```

It's off by default (API: `auto_start` on create and `PATCH`). A URL request is held while the sandbox starts, up to 20 seconds, then let through. Past that, a browser gets a "starting…" page that reloads itself, and other clients get `503` with `Retry-After: 5`. An API call waits up to 60 seconds, then answers `503 sandbox_starting` with `Retry-After`. A sandbox resumed within its warm window (10 minutes after the stop) is back in about a second. Each start is billed and limited like `froog start`: plan, wallet, concurrency and start rate limits apply. An API call gets the same error a start would (for example `429 limit_reached` or `402 insufficient_funds`). A visitor gets a plain `503` that doesn't say why. Private URLs check the token before anything starts. Over the API, the same rule holds for every call: a dashboard session, a full key, or a key with `sandbox.resume` or `sandbox.update` starts it. Any other key (a read-only one, or one with only `exec` or `file.read`) finds it stopped and gets the usual `409`, so handing out a narrow key never starts paid time. `wait` counts the start against its own timeout. Forks keep the setting. Each start is in the [audit log](https://froogcorp.com/docs/audit/) as `sandbox.auto_start`, by the key or by `public-url`.

### Stopping safely

A stop runs `~/.froog/pre-stop` if there is one, snapshots the disk, then drops the VM.

- **Pre-stop hook.** A script you write, for example to push work or stop a database cleanly. It runs as `user` from the home folder, for at most 60 seconds, on `froog stop`, auto-stop and billing stops (not on `--force`). Its output is in the [command history](https://froogcorp.com/docs/history/). If it fails or runs too long, the stop goes on.

- **Refused stop.** If the snapshot fails (usually because the disk is full), froog refuses the stop rather than lose your newest work. The sandbox keeps running and isn't billed. Its info shows `stop_refused_at` and `stop_error`, and you get an email and a `sandbox.stop_refused` [webhook](https://froogcorp.com/docs/webhooks/) event. froog tries again after 5, 15 and 30 minutes, then hourly. `froog start NAME` cancels the stop and billing resumes (the auto-stop timer and hard lifetime count again from then). A refused auto-stop is also cancelled by a new deadline (`froog extend NAME --hours N`, `PATCH ttl_secs`) or turning auto-stop off. Using the sandbox doesn't cancel it, just as it doesn't move the deadline. After 3 days of failed snapshots froog forces the stop, with an email the day before.

- **`froog stop NAME --force`** (API: `{"force": true}`) stops even if the snapshot fails, keeping the last good snapshot: what was written since is lost. It skips the pre-stop hook and everything that needs the sandbox to answer, so it also stops a hung sandbox, or one in `error` that still has a VM. With no earlier snapshot it keeps the VM and reports an error instead.

The stop itself (`GET /stops/{id}`) stays `failing`, with the snapshot error, while froog retries it under the same id. It ends `completed` when a retry goes through, or `superseded` when a start cancels it. A sandbox in `error` isn't billed. `last_reboot` in its info says when its OS last booted: a start from its disk, a restart after a crash or a host restart, or a reboot inside it (a warm resume isn't one).

### Disk space

froog checks each running sandbox's disk every 30 seconds (`disk_used_percent` in its info, `froog info`). At 85% full you get an email. At 95% froog clears caches that can be downloaded again (`sudo apt-get clean`, `sudo journalctl --vacuum-size=50M` and `docker image prune -f`, which removes only untagged images), at most once an hour and recorded in the command history, then emails you if it's still 95% full. You get at most one email per level per sandbox per day, each with a `sandbox.disk_low` webhook event. froog never deletes your own files. Free space before the disk fills: on a full disk snapshots fail, so stops are refused. For a bigger disk, `froog start NAME --size large` while it's stopped.

### Not answering

The 30-second check also notices a sandbox that stops answering: commands and SSH hang. After 5 minutes of failed checks it's marked unresponsive (`unresponsive_since` in its info, `not answering` in `froog info`), billing pauses, and you get an email and a `sandbox.degraded` webhook event. When it answers again billing resumes, with a `sandbox.recovered` event. After a crash a sandbox usually comes back by itself within minutes. If it doesn't, `froog stop NAME --force && froog start NAME` brings it back from its last good snapshot (what was written since is lost). After 3 days without an answer froog does that stop itself, with an email the day before.

### Errored sandboxes

A sandbox goes into `error` when its VM is lost (for example its host failed) or a start fails. It isn't billed in `error`. If it has no VM left and has a snapshot, froog rebuilds it on a new machine from its last snapshot by itself: up to 3 tries, 10 minutes apart. When it runs again you get an email and a `sandbox.recovered` event. Files written after that snapshot are lost and processes were restarted. If all 3 tries fail you get an email, and `froog start NAME` tries again. A sandbox in `error` that still has its VM (a failed stop) is left alone so nothing on its disk is lost. Turn rebuilds off under **Alerts** in the dashboard, `froog alerts off auto-rebuild`, or `PATCH /v1/account/alerts {"auto_rebuild": false}`.

### Alert emails

froog emails the account about: `disk` (85% or 95% full), `stop_refused` (a refused stop, and the day before it's forced), `unresponsive` (a sandbox not answering, and the day before it's stopped), `rebuilt` (an automatic rebuild, or its failure), `plan_nudge` (another plan fits your last 30 days better, at most once a billing period) and `billing` (the grace period, sandboxes stopped for money, a failed auto top-up, the end of your trial). All are on by default. Turn each off under **Alerts** in the dashboard or with the CLI. Webhook events are sent either way.

```sh
$ froog alerts                         # what's on
$ froog alerts off plan_nudge disk
$ froog alerts on disk
```

API: `GET /v1/account/alerts` answers `{emails: [{kind, description, enabled}], auto_rebuild}`, and `PATCH /v1/account/alerts {"emails": {"plan_nudge": false}, "auto_rebuild": true}` changes what you name (an unknown kind is `400`). Reading needs `account.read`, changing `account.admin`. Changes are in the [audit log](https://froogcorp.com/docs/audit/) as `alerts.update`. The plan suggestion is also in `GET /v1/limits` as `plan_nudge: {usd, used_usd, text}`, only for a paid plan held 30 days or more: a cheaper plan when your last 30 days' running time fits it, or a larger one when you paid past your plan from the wallet.

### Metrics

```sh
$ froog metrics ci-1
cpu      12.5% of 4 vCPU
memory   1.9 GB of 8.2 GB (23%)
disk     6.0 GB of 52.7 GB (11%)
network  0.3 GB in, 0.0 GB out since boot
io wait  0% of the last minute waiting on disk
```

`GET /v1/sandboxes/{name}/metrics` reads the numbers from the running sandbox when you ask (it takes about a second): CPU use over half a second, memory, the root disk, network bytes since boot (all interfaces but loopback) and disk I/O pressure. A stopped sandbox answers `409`, and one with [monitoring off](https://froogcorp.com/docs/data/) `409 monitoring_off`.

```sh
$ froog metrics ci-1 --history --since 6h
cpu     ▁▁▂▇█▇▂▁▁▁▁▁▃▅▂▁  max 97%
memory  ▃▃▃▅▆▆▅▃▃▃▃▃▃▄▃▃  max 71%
disk    ▂▂▂▂▂▂▂▂▃▃▃▃▃▃▃▃  max 24%
```

froog also keeps CPU, memory and disk every 30 seconds while a sandbox runs, for 7 days: `froog metrics NAME --history [--since 6h]`, the Metrics button on a sandbox in the dashboard (graphs for 1 hour to 7 days), or `GET /v1/sandboxes/{name}/metrics/history?since=&until=&step=`. `since` defaults to an hour ago and `until` to now (unix seconds). Points average `step_secs` seconds, a multiple of 30 (default the window divided by 360, at most 1000 points), each `{at, cpu_percent, cpu_max_percent, memory_used_mb, memory_total_mb, disk_used_percent, samples}`. Times when it was stopped have no points. With monitoring off nothing is kept (`409 monitoring_off`). For your own dashboards, use the [OpenTelemetry export](https://froogcorp.com/docs/telemetry/).

### Labels

Tag sandboxes with your own key-value labels, such as the user, team or job they belong to, and filter lists by them:

```sh
$ froog new ci-1 --label team=web --label job=1234
$ froog label ci-1 tier=gold job-     # set tier, remove job
$ froog list --label team=web --state running
$ froog list --prefix ci-
```

Up to 32 labels per sandbox. Keys are 1-63 letters, digits, `.`, `_`, `-` and `/`, starting with a letter or digit. Values are up to 255 characters on one line, and may be empty. A fork keeps its source's labels, with any you give it on top. `froog info` shows them.

## Pricing

The short version, with a comparison to other providers, is on the [pricing page](https://froogcorp.com/pricing/).

**Running time is billed per second, only while a sandbox runs: small $0.0137 an hour, default $0.0274, large $0.0685 (xlarge, $0.137, is coming soon).** A full 730-hour month is $10, $20, $50 and $100. A stopped sandbox costs no running time.

### Plans

A plan is needed to start sandboxes. Every plan dollar comes back as usage, so a plan is not a fee. Each one buys one VM of the matching size running all month, or the same money spread over many short runs: the $20 plan is about 730 hours of `default`, however you split it across runs.

| Plan | Usage included | Sandboxes at once | Saved snapshots included |
| --- | --- | --- | --- |
| $10 a month | $10 | 2 | 10 |
| $20 a month | $20 | 5 | 10 |
| $50 a month | $50 | 10 | 25 |
| $100 a month | $100 | 25 | 50 |

`xlarge` sandboxes are coming soon, for the $100 plan and higher.

- `froog billing plan 20` chooses a plan. The first one opens Stripe Checkout in your browser (froog never sees your card number) and waits until you're subscribed. Your bank may ask you to confirm the card (3D Secure).

- Plan changes apply at once. An upgrade charges the difference for the rest of the period, and that money comes back as usage too. A downgrade gives no credit: the new plan's limits apply at once, this period's paid usage stays, and the next period costs the new price. Plans can't change while a payment is outstanding. `froog billing cancel` ends the plan at the end of the period.

- Unused plan usage expires at the end of each billing period. It doesn't carry over.

- Bigger plans are by request: $250 a month (60 sandboxes at once, 125 saved snapshots) and $500 (125 at once, 250 saved snapshots). Email [hello@froog.tech](mailto:hello@froog.tech) with what you'll run and we'll turn them on for your account. Until then, choosing one answers `403 plan_by_request`, and `froog billing` lists only the plans you can choose. For more than 125 sandboxes at once, email us too.

### Pause instead of cancel

`froog billing cancel --keep-disks` (or **Pause** on the dashboard's Billing tab, or `DELETE /v1/billing/plan?keep_disks=true`) cancels the plan at the end of the period, like a cancel, and you pay nothing more. When the plan ends, every running sandbox stops, also with money in the wallet. froog keeps the disks of your stopped sandboxes for 90 days after the paid period, not 14, and saved snapshots past those included cost nothing for those 90 days. Starting a sandbox answers `402 plan_paused`. Choose a plan again (`froog billing plan 20`) to resume: everything works as before. After the 90 days the usual rules apply. `froog billing` shows the date.

### Wallet

The wallet is a prepaid balance in US dollars that pays for usage past your plan. `froog wallet pay 15` adds $15, charged to your saved card (whole dollars, $15 minimum). Wallet charges are capped at $1,000 a day per account, payments and auto top-ups together, so a leaked API key can't run up your card. `froog wallet auto 20` turns on auto top-up: it charges $20 whenever plan usage left plus the wallet drops below 10% of that amount (never below $5). `froog wallet auto off` turns it off. Auto top-up doesn't run during the free trial, and it turns itself off after 3 declined charges in a row. The wallet needs a plan.

**Bonus.** A payment of $200 or more gets a bonus, and auto top-ups get the same. It grows evenly on a log scale, from 2% at $200 to 25% at $10,000 (and 25% above). `froog wallet quote 500`, `froog wallet pay` and the dashboard show it before you pay. The bonus is its own line in the wallet, and the daily cap counts only what your card paid.

| You pay | Bonus | The wallet gets |
| --- | --- | --- |
| $15 to $199 | none | the same amount |
| $200 | +2% | $204 |
| $500 | +7.4% | $537 |
| $1,000 | +11.5% | $1,115 |

Payments above $1,000 a day need a higher cap: email [hello@froog.tech](mailto:hello@froog.tech). With it, $2,000 gets +15.5%, $5,000 +20.9% and $10,000 +25%.

### Running out

When this period's plan usage and the wallet are both used up, new starts fail with `402 insufficient_funds` and running sandboxes get a 24-hour grace period: they keep running, and you get an email. During the grace period the wallet goes below zero: its usage is debt, also when the plan itself ended. If you don't pay, the sandboxes are stopped when it ends, each stop snapshotting the disk, so nothing is lost. They're stopped sooner if the grace period adds more new debt than 25% of the plan's price, or $5 on the smaller plans. Paying into the wallet (or choosing a plan again) lifts it, the debt is paid first, and you can start them again.

### Storage and invoices

- Stopped sandboxes keep their disk for 14 days, free. Automatic snapshots are free. Saved snapshots past your plan's included number (10 to 50, more on bigger plans) cost $0.056 per snapshot per day, from the wallet. With an empty wallet, saving one more under a new name fails with `402 credit_required` (saving under a name you already have replaces it, so it still works).

- Network traffic (no egress charges), public URLs, the desktop and `/dev/kvm` are included.

- Stripe charges the plan monthly. Wallet payments are separate charges. `froog billing portal` changes the card and downloads invoices.

- `froog billing` shows your plan, its renewal, this period's usage against what's included, the wallet and the rates. `froog usage` breaks the period down by size, and `froog usage --month 2026-10` shows a past calendar month at list prices. `froog list` and the dashboard show what each sandbox's running time cost this billing period, and `froog usage NAME --since 2026-11-01 --until 2026-11-08` one sandbox's hours and cost in any window (also `7d`, a time in UTC, or unix seconds). Saved snapshots are billed per account, so they aren't in a sandbox's cost.

### Free trial and limits

Every new account gets a **7-day free trial**, starting with its first plan. A card is needed, and nothing is charged for the trial. It includes 25 hours of running time in total, up to 2 sandboxes at once, in the `small` and `default` sizes, and up to 5 starts a minute, 25 an hour and 75 a day. Every sandbox needs an auto-stop timer of 2 hours or less (the trial's default): no auto-stop or a longer timer fails with `403 trial_auto_stop_required`, and a start gives a new window of 2 hours or less. The trial can't save new saved snapshots (`403 trial_no_saved_snapshots`). Ones you have stay. Past 25 hours, running sandboxes stop and starts fail with `429 trial_compute_limit_reached`. The limits stay until the plan's first payment. One trial per person: each account gets one, and a card that already had a trial on another account gets none. That plan starts paid right away (and if its charge fails, starts fail with `402 trial_blocked`). `froog billing end-trial` ends the trial early and starts paying for the plan. If that charge fails, nothing changes: the trial goes on (`402`). If the charge fails when the trial ends on its own, the plan's status is `unpaid` and starts fail with `402 subscription_required` until it's paid (`froog billing portal`).

After the trial, an account runs as many sandboxes at once as its plan allows, and any account can start up to 20 sandboxes a minute, 100 an hour and 500 a day. `froog limits` shows your limits, how much of each you've used, and the trial's days and hours.

### Extra starts

Past a start limit, a start fails with `429`, unless you turn on extra starts: `froog billing extra-starts on` (`PUT /v1/billing/extra-starts {"enabled": true}`). Then a start above the included number is paid from the wallet. It's off by default, and it needs an active paid plan: not the trial, a plan that's past due, or one set to end (`409 requires_active_plan`).

| Start | Price | A day, with 500 included |
| --- | --- | --- |
| Included | free | 1 to 500 |
| Up to 1.5 times included | $0.03 | 501 to 750 |
| Up to 2 times | $0.05 | 751 to 1,000 |
| Up to 6 times | $0.07 | 1,001 to 3,000 |
| Above 6 times | fails | 3,001 and up |

The prices apply per minute, hour and day, and a start pays the highest of the three. The most per minute is 3 times, not 6. When the wallet can't pay, the start fails with `429` and says what it would cost. Nothing is queued. Each payment is a `start` line in the wallet.

## Background commands

For a long job or a server that never ends, run the command in the background. It keeps running after your terminal disconnects.

```sh
$ froog exec my-box --detach -- 'npm run build && npm test'
job_3f2a9c1d8e7b6a50
$ froog logs my-box job_3f2a9c1d8e7b6a50 -f    # follow until it ends; exits with its exit code
$ froog jobs my-box                            # list: running, exited, killed
$ froog kill my-box job_3f2a9c1d8e7b6a50       # stop it and everything it started

$ froog exec my-box --timeout 0 -- ./long-script.sh   # start it and stream it to the end
```

Output (stdout and stderr together) and the exit code are kept on the sandbox's own disk under `~/.froog/jobs/`, so they survive a stop. Running processes don't: a job that was running when the sandbox stopped shows as `lost` (or `paused` during a [warm resume](https://froogcorp.com/docs/snapshots/#warm) window, after which it carries on). A running background command doesn't hold off [auto-stop](https://froogcorp.com/docs/sandboxes/#auto-stop): give the sandbox a timer long enough for it (`froog extend NAME --hours 8`). Every background command also gets a record in the [command history](https://froogcorp.com/docs/history/), which you can read while the sandbox is stopped.

### One-off runs and pushing a folder

```sh
$ froog run -- npm test                       # new sandbox, this folder in ~/FOLDER, run, print the cost, delete
$ froog run --from web-stack --size large -- make e2e
$ froog run --keep -- ./build.sh              # keep it afterwards
$ froog run --push ./api --timeout 1800 -e NODE_ENV=test -- bun test
$ froog push my-box                           # this folder to my-box:~/FOLDER
$ froog push my-box ../api --to src/api
```

`froog run` streams the command's output and exits with its exit code, so it works in CI and scripts. The last line, on stderr, sums it up: `exit 0 · 4m 12s · large · $0.0048 · run-x7k2 deleted`. The sandbox has a 15-minute auto-stop that `froog run` pushes back every 5 minutes while it runs, and snapshots off, so if `froog run` dies, the sandbox stops and is erased on its own within 15 minutes. Ctrl-C stops the command and deletes the sandbox (press it twice to exit at once). With `--keep` the sandbox stays, with snapshots on (your account's setting), until that auto-stop, which keeps its disk: `froog extend NAME --hours 4` for longer, `froog start NAME` after it stopped. `froog push` uploads a folder as one archive through the API (no SSH needed): in a git repo, the tracked files and the untracked ones that aren't ignored. Elsewhere every file except `.git`, `node_modules`, `target`, `.venv`, `dist`, `build` and the like. Up to 100 MiB compressed. Existing files with the same names are overwritten. Others stay.

| `froog run` flag | Default | What it does |
| --- | --- | --- |
| `--push DIR` | this folder | Push this folder to `~/DIR-NAME` first and run the command there. |
| `--no-push` | off | Push nothing. The command runs in `~`. |
| `--cwd DIR` | the pushed folder | Run in this directory, relative to `~` or absolute. |
| `--timeout SECS` | no limit | Stop the command after this many seconds and exit `124`. |
| `-e`, `--env KEY=VALUE` | none | A variable for the command. `KEY` alone copies it from this shell. Repeat it. |
| `--from SAVED`, `--size` | `default` | Start from a saved snapshot. `small`, `default` or `large`. |
| `--no-env` | off | None of your account's [environment](https://froogcorp.com/docs/environment/): no variables, repos, secret files or setup script (also `--no-environment`). |
| `--keep` | off | Keep the sandbox afterwards, with snapshots on. It stops at its 15-minute auto-stop. |
| `--json` | off | One line `{"type":"output","data":…}` per chunk of output on stdout, then `{"type":"summary",…}` with `name`, `exit_code`, `timed_out`, `interrupted`, `elapsed_secs`, `cost_usd` (null when not billed), `size`, `deleted` and `kept`. |

`froog exec --cwd DIR` runs a command in a directory (relative to `~`), and `-e KEY=VALUE` (repeat it) sets a variable for that command only. Both work with `--detach` and `--timeout 0`. The history keeps the command, not the values.

**Standard input.** Pipe or redirect into `froog exec` and the command reads it:

```sh
$ froog exec my-box -- python3 - < script.py
$ git diff | froog exec my-box --cwd app -- git apply
$ froog exec my-box --detach -- ./import.sh < rows.csv    # a background job's stdin, given at start
```

Up to 1 MiB. For more, upload a file and read it from there. `froog exec` sends stdin only when it's a pipe or a file. A pipe that sends nothing for 3 seconds and stays open (as some agent harnesses leave it) is ignored, with a note on stderr. `--no-stdin` skips it (like `ssh -n`, e.g. inside a `while read` loop). The API takes `stdin` (text) or `stdin_b64` (base64, for binary) on `POST /exec`, streamed or not, and on `POST /jobs`. The input ends after it, so a command reading to the end finishes. More than 1 MiB is `413 stdin_too_large`. A background job's input is kept beside its output, in `~/.froog/jobs/<id>/stdin`.

**Writing to a running job.** Start it with `--stdin-open` and its input stays open: write to it whenever you like, and close it to send end of file.

```sh
$ froog exec my-box --detach --stdin-open -- python3 -i
job_3f2a9c1d8e7b6a50
$ froog stdin my-box job_3f2a9c1d8e7b6a50 'print(6 * 7)'   # a newline is added
$ cat more.py | froog stdin my-box job_3f2a9c1d8e7b6a50   # or pipe it in
$ froog stdin my-box job_3f2a9c1d8e7b6a50 --close         # end of file: python3 exits
```

The API: `stdin_open: true` on `POST /jobs` (input given there with `stdin` comes first), then `POST /jobs/{id}/stdin` with `{data?, data_b64?, close?}`, up to 1 MiB per call. The input is a pipe (`~/.froog/jobs/<id>/in`): writes arrive in order, and a command that doesn't read its input fills it after 64 KiB, so a write that can't finish in 30 seconds fails with `409 stdin_blocked` (part of it may have gone in). Writing to a job started without `stdin_open` is `409 stdin_not_open`, after `close` `409 stdin_closed`, and after the job ended `409 job_ended`. Stopping the job closes its input too.

## Command history

Every command you run through `froog exec` or the API gets a record on the control plane: its text (first 64 KiB), mode, status, exit code, start and end time, and its output. Records are kept 90 days and can be read while the sandbox is stopped. In the dashboard, click **History** on a sandbox.

```sh
$ froog history my-box
cmd_4b1e0c9a7d2f3e18  exit 0      detached  2026-11-10 14:02:11  npm run build
$ froog history my-box cmd_4b1e0c9a7d2f3e18             # stdout; exits with the command's exit code
$ froog history my-box cmd_4b1e0c9a7d2f3e18 --stderr
$ froog history my-box cmd_4b1e0c9a7d2f3e18 --follow    # until it ends
$ froog history my-box --mode detached                  # sync, stream or detached
$ froog ps my-box                                       # processes, busiest first
$ froog ps my-box --history --since 6h                  # the samples, every 30 s
```

| Modes | `sync` (`froog exec`, waits for it), `stream` (`--timeout 0`), `detached` (`--detach`). `exec` answers with the `command_id`, and a background job's `command_id` is in its start answer. |
| --- | --- |
| Status | `running`, `paused` (frozen with a stopped sandbox whose memory is kept, and a start resumes it), `exited`, `timed_out`, `failed` (it couldn't run), `killed`, `lost` (the sandbox stopped or restarted under it). |
| Output | stdout and stderr, compressed. A waiting command stores its output when it returns (up to 1 MiB per stream, the same as its answer). A background command's output (stdout and stderr together, under stdout) is copied every 30 seconds, and once more as the sandbox stops. Up to 8 MiB per stream. |
| Cap | An account stores at most 10 MiB of compressed output per hour (about 50 MiB of text). Output past either cap is dropped and the record shows `output_truncated`. |
| Deletion | Deleting a sandbox deletes its history. |

Reading output: each read returns at most 1 MiB from `offset`. Read again from `next_offset` until `done` is true. Pass `encoding=base64` for the exact bytes of binary output.

`froog ps` lists a running sandbox's processes live, with the history record of each background command's processes in the HISTORY column. froog also samples the 12 busiest processes every 30 seconds while a sandbox runs, and once as it stops, and keeps the samples 7 days: `froog ps NAME --history`, or `GET /v1/sandboxes/{name}/processes/samples?since=&until=&limit=` (newest first). A stopped sandbox's `froog ps` shows its last sample with `live: false`. With [monitoring off](https://froogcorp.com/docs/data/) no samples are taken. Only commands run through froog are recorded, not the output of SSH sessions or services.

Process records cover everything else that runs, such as services, SSH sessions and what an agent starts: `froog ps NAME --records [--running] [--since 6h]` or `GET /v1/sandboxes/{name}/processes/records?since=&until=&running=&limit=` (newest start first, `limit` 1-1000, default 200). Each is `{pid, ppid, user, command, started_at, last_seen_at, ended_at}`, kept 72 hours after the process ends. They come from the 30-second checks, so they're an approximation: a process that starts and ends between two checks isn't recorded, and it ended between `last_seen_at` and `ended_at`. Kernel threads and froog's own check are left out. Commands run through froog show here too. Output and exit codes of these processes aren't kept (that would need an agent inside the sandbox). Not kept with monitoring off.

A command the kernel killed for using too much memory (exit code 137, with an out-of-memory kill in the kernel log while it ran) has `oom_killed: true` in its `exec` answer and its record, and `froog exec` says so. The sandbox stays up. A larger size has more memory.

## Public URLs

Give a port inside a sandbox a public HTTPS address, for a preview, a webhook receiver or a demo:

```sh
$ froog exec my-box --detach -- 'cd app && npm run dev -- --port 3000'
$ froog host my-box 3000
https://my-box-3000-k7f2qx.froogusercontent.com/?_token=pt_9c1e…
$ froog host my-box 3000 --public              # no token: anyone with the address
$ froog host my-box 3000 --subdomain acme-demo  # https://acme-demo.froogusercontent.com
$ froog host my-box 3000 --title "API docs"     # a label in listings
$ froog host my-box                             # list
$ froog host my-box 3000 --rm                   # remove
$ froog new web --host 3000:public              # a URL from the start (repeat --host; PORT alone is private)
```

The service can listen on `localhost` or `0.0.0.0`. A private URL opens nothing on froog's firewall. A `--public` one also opens its TCP port on the sandbox's IPv6 address (see [Network](https://froogcorp.com/docs/network/)), and closes it again when the URL is removed or made private. Traffic through the URL, websockets included, reaches it through an SSH tunnel from froog's edge (it doesn't hold off [auto-stop](https://froogcorp.com/docs/sandboxes/#auto-stop)). The URL stays the same across stop, start and restore. While the sandbox is stopped, visitors see a short notice, or, with [auto-start](https://froogcorp.com/docs/sandboxes/#auto-start), it starts. A new URL is **private**, like boat's: it carries a `_token`. The first visit redirects to the same address without it and sets a cookie (`__Host-froog_url`) for that host, so the link works in a browser. An API client sends `Cookie: __Host-froog_url=<token>` instead (or follows the redirect with a cookie jar: `curl -L -c jar -b jar URL`). Treat the full link like a password. `--public` drops the token. Hosting the same port again without it makes a new token, so `--public` then plain `froog host` replaces a leaked one. A private URL in an iframe on another site may stay blank (browsers block third-party cookies): use `--public` for embeds. `--subdomain` renames a URL (3-40 lowercase letters, digits and hyphens, or `409 subdomain_taken` if someone has it), and the old address stops working. `--title` labels a URL in listings (up to 100 characters, hosting again without it keeps it, `--title ""` removes it). Up to 50 per sandbox, and 20 new addresses per account a day. The first visit to a new URL takes a few seconds while its certificate is issued.

**Wait for a port.** To know when the server you started is up, wait for its port: `froog wait my-box --port 3000 --timeout 2m` returns once something listens on it inside the sandbox, on any address, and exits 1 if nothing does in time (default 60 seconds, at most 10 minutes). API: `POST /sandboxes/{name}/wait {port, timeout_secs?}` answers `{port, ready, waited_ms}`, with `ready: false` after the timeout. A sandbox that is still starting, or stopped with auto-start, counts against the same timeout. It needs only `sandbox.read` (starting an auto-start sandbox needs `sandbox.resume` or `sandbox.update`, as for [every call](https://froogcorp.com/docs/sandboxes/#auto-start)), and it isn't recorded in the command history.

**A token per viewer.** To share a private URL with several people and cut one off later, give each their own token. It expires (default a day, 60 seconds to 30 days) and can be revoked without touching the others or the URL's own token:

```sh
$ froog url-token create my-box 3000 --ttl 2h --label alice
https://my-box-3000-k7f2qx.froogusercontent.com/?_token=pvt_51d0…
$ froog url-token list my-box 3000
$ froog url-token revoke my-box 3000 utk_…
```

A viewer token works like the URL's own: as `?_token=` (turned into a cookie that lasts until the token expires) or in the cookie. Every request is checked, so a revoked or expired token stops working at the viewer's next request. Any private URL token, the URL's own included, can also go in an `X-Froog-Url-Token` header, for scripts and API clients that don't keep cookies. API: `POST /sandboxes/{name}/urls/{port}/tokens {ttl_secs?, label?}` returns `{id, token, url, expires_at}` (the secret only this once), `GET` lists the live ones, `DELETE …/tokens/{id}` revokes one. Up to 100 live tokens per URL. Public URLs have none (`409 url_public`). Making a URL public, or removing it, deletes its viewer tokens.

## Snapshots & forks

A snapshot is a copy of the sandbox's whole disk.

- **Automatic snapshots** are taken every hour while a sandbox runs, and when it stops. The 3 most recent are kept. They're free.

- **Saved snapshots** are kept until you delete them: `froog snapshot my-box --name before-upgrade`.

```sh
$ froog snapshots my-box                        # list
$ froog fork my-box@before-upgrade my-box-2     # new sandbox from a snapshot
$ froog fork my-box try --count 5               # try-1 … try-5
$ froog restore my-box@before-upgrade           # roll back in place
```

On the [dashboard](https://froogcorp.com/dashboard/), **Snapshots** on a sandbox shows its snapshots on a timeline and the sandboxes forked from them. Pick one to restore to it, fork it or copy its command. In the API, a sandbox forked or started from a saved snapshot has `forked_from`: the source sandbox's name, the snapshot's `id` and name, and when it was copied.

### Saved snapshots as templates

A saved snapshot belongs to your account, not just its sandbox: install your stack once, save it under a name, then start as many sandboxes from it as you like. It stays when its sandbox is deleted.

```sh
$ froog snapshot my-box --name web-stack     # save the disk under a name
$ froog new app-1 --from web-stack           # a new sandbox with that disk (and its OS and size, unless --size)
$ froog new app --from web-stack --count 5   # app-1 … app-5
$ froog snapshots                            # your saved snapshots
$ froog snapshots --rm web-stack             # remove it
$ froog snapshots --all                      # every snapshot of every sandbox
$ froog snapshot my-box --delete-auto        # delete its automatic snapshots
```

- Names are unique in your account. Saving a name again replaces it once the new copy is ready (in an organization, only its creator's sandboxes or an owner or admin may: `403 saved_snapshot_name_taken`). Sandboxes already started from it don't change. To keep the old one too, save under a new name (`web-stack-v2`).

- A sandbox from a saved snapshot gets your [environment](https://froogcorp.com/docs/environment/) (unless `--no-environment`), not the source sandbox's settings.

- Saving a running sandbox snapshots its disk now, in the background (`status` goes from `creating` to `available`), and a stopped one saves its last snapshot.

- The first 10 saved snapshots are included. Each one past that is $0.056 a day, from the wallet: with an empty wallet, a new one fails with `402 credit_required`. `froog snapshots` and `froog limits` show how many you keep.

### Share a saved snapshot with another account

A share code lets any froog account start sandboxes from one of your saved snapshots, without access to your account.

```sh
$ froog snapshot share web-stack              # prints the code once: fsh_…
$ froog snapshot shares web-stack             # its codes: uses, last use, active or revoked
$ froog snapshot unshare web-stack shr_…      # revoke one
$ froog new app --from fsh_…                  # the other account (or --from-share fsh_…)
```

- froog keeps only a hash of the code. Lost it? Revoke it and make a new one. Up to 20 live codes per saved snapshot.

- A code stays with the sandbox the name was saved from when you made it. It starts the newest copy of the name from that sandbox: saving `web-stack` again from it changes what the code gives. Saving `web-stack` from another sandbox revokes the name's codes (make a new one to share that disk). Removing the saved snapshot revokes its codes, and so does `unshare`. A revoked or unknown code gets `404 share_code_invalid`.

- The other account gets a copy of the disk as a saved snapshot of its own, under the same name (or `web-stack-2` when it has one), marked `shared`, with the code's id as its `source`. That copy is theirs: it stays after you revoke the code or delete yours, and it counts and bills as one of their saved snapshots. Their sandbox bills their account and gets none of your variables. They can't share it on.

- Their sandbox starts without their own [environment](https://froogcorp.com/docs/environment/) too (as with `--no-environment`): the disk was prepared by another account, and anything on it (a startup script, a changed shell profile) would see their keys and files. `froog new app --from fsh_… --environment NAME` (API: `"environment": true`) opts in. Only do that for a disk you trust. The same goes for later sandboxes from their copy (`--from web-stack`).

- Before their sandbox counts as running, froog removes your logins from its disk ([the list](https://froogcorp.com/docs/environment/#scrub)), plus your environments' secret files and the repos they clone, and discards the freed space. Everything else on the disk is shared, so don't keep secrets in other files. Browsing or pulling their copy (possible before it ever boots) leaves the same paths out, and also all of `~/.ssh`, `~/.claude.json`, `~/.gitconfig`, `~/.froog/env`, the same paths under `/root`, `/var/lib/cloud/instances` and `/var/log/cloud-init*`.

- Over the API: `POST /saved-snapshots/{name}/shares` (needs `snapshot.write`), `GET` the same path, `DELETE /saved-snapshots/{name}/shares/{id}`. Starting from a code is `POST /sandboxes {"from": "fsh_…"}` and needs `snapshot.write` (it saves the copy).

### Look inside a snapshot

List and download files from any snapshot without starting anything: the sandbox can be stopped, or deleted for a saved snapshot.

```sh
$ froog snapshot ls web-stack                    # a saved snapshot's ~
$ froog snapshot ls my-box@latest app/src        # a sandbox's newest snapshot; relative paths are under ~
$ froog snapshot pull my-box@latest              # all of ~ into ./my-box-latest
$ froog snapshot pull web-stack ~/app -o ./app   # one folder
$ froog snapshot pull my-box@before-upgrade /etc/nginx/nginx.conf
```

- A snapshot is a saved snapshot's name, or `SANDBOX@SNAPSHOT` (an id, a label, or `latest`). Paths default to `~` (`/home/user`). Absolute paths reach the whole disk.

- `pull` writes a file as is, and a folder's contents into the `-o` directory. It skips symbolic links that point outside it (absolute, or with `..`) and says how many.

- API: `GET /sandboxes/{name}/snapshots/{snap}/files?path=P` and `GET /saved-snapshots/{name}/files?path=P` answer like `GET /sandboxes/{name}/files` (`{path, kind, size, modified, entries}`). `…/files/download?path=P` streams a file's bytes, or a folder as a tar whose entries are named from `/` (`home/user/app/…`). `X-Froog-Entry-Kind` says `file` or `dir`. A key needs `snapshot.read` and `file.read`.

- On our hosts, froog mounts the disk read-only for the request, and never follows links inside it. It takes a few seconds before the first byte. Only ext4 and XFS disks can be looked inside (all of froog's images but Omarchy, whose disk is btrfs): others answer `501 not_supported` (`can't look inside a "btrfs" filesystem`).

**A stopped sandbox keeps its disk for 14 days.** After that its automatic snapshots are deleted, and `start` and `fork` fail with `snapshot_data_expired`. To keep a disk longer, save a snapshot before then (`froog snapshot my-box --name keep`): saved snapshots never expire, and `froog restore my-box@keep` brings the sandbox back from one. `froog list --json` and the dashboard show each stopped sandbox's `disk_expires_at`.

**Warm resume.** For 10 minutes after a stop, the sandbox's VM is kept paused with its memory: an `froog start` in that window resumes it in about a second, with every program still running, the same address, and background commands carrying on. The stop still snapshots the disk and billing stops as usual. After 10 minutes, or if the host needs the memory, the paused VM goes and a start boots from the snapshot (as below). `froog info` and the API's `warm_until` say until when. Meanwhile, commands and background jobs that were running show as `paused` in `froog history` and `froog jobs`. A resume sets them `running` again, a fresh boot `lost`. A start at another size, a restore, or a host restart also boot from the snapshot. Don't count on warm resume for anything that must survive: save state to disk.

`restore` snapshots the current state first, so a restore can itself be undone. `froog stop` snapshots the disk and deletes the VM, and `froog start` brings it back from that snapshot: the disk is identical, but its SSH address (IP or port) can change.

**A snapshot on a full host.** A snapshot lives on the host its sandbox ran on. When a start, fork or `new --from` needs it and that host has no room (or is being emptied for maintenance), froog copies the disk to the host with the most room and starts the sandbox there, instead of failing. That adds the copy's time to the start (the disk goes from host to host compressed, holes skipped, so what counts is the space it uses). Meanwhile the sandbox stays `starting` (or `creating`), and `progress` in `froog info` and the API says `copying its disk from host … to host …`. A copy gives up after 2 hours. When no host has room, the start fails as before (`no free capacity`).

Snapshots copy the full disk, so they take time: in our tests about 35 seconds for a 3 GB disk, longer as the disk fills. Forks and starts take about 20–30 seconds. Stopping takes about 2 minutes.

### Start times

froog times every sandbox that comes up, from the API call to the moment it accepts SSH (when the CLI, the dashboard and the API report it `running`). The [status page](https://froogcorp.com/status/) publishes the median and the 95th percentile for the last 7 days (API: `start_times` in `GET /v1/status`, no key needed). These are our targets for the 95th percentile (measured on November 12, 2026: about 16 seconds to create, 18 to start, 20 to fork, and under a second for a warm resume):

| What | Target (95th percentile) | What it includes |
| --- | --- | --- |
| New sandbox (`create`) | 45 s | A VM from the base image or a saved snapshot, boot, SSH, environment variables |
| Start a stopped sandbox (`start`) | 45 s | A VM from its last snapshot, boot, SSH |
| Warm resume (`resume`) | 3 s | A start within 10 minutes of the stop: unpausing the VM it kept |
| Fork (`fork`) | 60 s | A snapshot of the running source, then a VM from it |

Not included: a disk copied from a full host ([above](https://froogcorp.com/docs/snapshots/#other-host)), which adds the copy, and the setup your [environment](https://froogcorp.com/docs/environment/) runs after boot (repos, setup scripts), which runs once the sandbox is already `running`. A start that fails isn't counted. It shows as `error` and in your alerts.

## Coding agents

`froog prompt` gives a coding agent in the sandbox a task and prints its work (its text on stdout, tool calls on stderr) until the turn ends:

```sh
$ froog prompt my-box "make the failing tests pass"
$ froog prompt my-box "now add a test for the edge case"      # same conversation: it remembers
$ froog prompt my-box "add a /health endpoint" --agent codex --new
$ froog prompt my-box "review the diff" --model opus --effort high --fast
$ froog prompt my-box "why does this crash?" --attach error.png --attach trace.log
```

Ten harnesses are installed in every sandbox. Pick one with `--agent` (API: `agent`), a model with `--model` and a reasoning level with `--effort`. The model and effort are passed straight to the harness. `froog prompt --list-models` (API: `GET /v1/provider-models`, no key needed) prints each harness's models, effort levels and which have fast mode. `froog prompt my-box --list-models` adds the models OpenCode and pi list in that sandbox with its keys.

| `--agent` | Harness | Key or login | `--model` · `--effort` |
| --- | --- | --- | --- |
| `claude` (default) | Claude Code | `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, a [Claude subscription](https://froogcorp.com/docs/agents/#subscriptions) or a login | yes · `low` … `max` |
| `codex` | OpenAI Codex | `OPENAI_API_KEY`, a [ChatGPT subscription](https://froogcorp.com/docs/agents/#subscriptions) or a login | yes · `minimal` … `xhigh` |
| `gemini` | Gemini CLI | `GEMINI_API_KEY` or a Google login | yes · no |
| `opencode` | OpenCode | the key of the provider you use | yes (`provider/model`) · no |
| `pi` | pi | the key of the provider you use | yes · `off` … `max` |
| `amp` | Amp | `AMP_API_KEY` (an access token) | no · no |
| `cursor` | Cursor CLI | `CURSOR_API_KEY` | yes · no |
| `prime` | Prime Agent | the key of the provider you use (e.g. `ANTHROPIC_API_KEY`, `PRIME_API_KEY`) | yes · `off` … `max` |
| `kimi` | Kimi Code | `MOONSHOT_API_KEY` or `kimi login` (a Kimi Code plan) | yes · `low` … `max` |
| `mistral` | Mistral Vibe | `MISTRAL_API_KEY` or `vibe --setup` | yes · no |

When a harness has neither its key nor a login in the sandbox, the prompt is refused before it runs with `400 provider_not_configured`, saying how to set it up. Give agents their API keys once, as [environment variables](https://froogcorp.com/docs/env/), and every sandbox has them:

```sh
$ froog env set ANTHROPIC_API_KEY      # prompts for the value; for Claude Code
$ froog env set OPENAI_API_KEY         # for Codex
```

Or connect a [subscription](https://froogcorp.com/docs/agents/#subscriptions), or sign in inside the sandbox once: `froog ssh my-box`, then run `claude` (or `codex`) and follow its login. A login made inside stays on the sandbox's disk, so forks and restarts keep it.

**Fast mode.** `--fast` (API `fast: true`) runs the same model with faster output, billed by the provider at its higher fast rate: Claude Code's Opus fast mode, Codex's fast service tier, Amp's `--fast`. Models without it (see `--list-models`) answer `400 fast_mode_not_supported`. `fast: false` forces standard speed.

### Conversations

A conversation is a thread of prompts with the agent's memory: each turn continues the harness's own session (Claude Code `--resume`, `codex exec resume`, and the same for the others). A prompt continues the sandbox's current conversation (the most recently prompted one). `--new` starts another. `--conversation ID` continues a given one. Each prompt prints its conversation id first.

```sh
$ froog prompt my-box --new "look into the flaky CI job"     # runs beside the others
$ froog conversations my-box                                 # id, when, prompts, running, agent/model, last prompt
$ froog conversations my-box conv_3f9a…                      # its turns; --delete removes it
$ froog prompt my-box --conversation conv_3f9a… --agent codex "take it from here"
```

A conversation runs one turn at a time: a second prompt to it waits as `queued`. Different conversations run at the same time, up to 4 per sandbox. More wait in the queue. A prompt's status is `queued`, `running`, `done`, `failed` or `interrupted`. Switching harness in a conversation gives the new one the earlier turns (prompts and answers, up to 16 KB) in its first prompt. The sessions live in the sandbox's home, so conversations survive a stop, start or fork. A running prompt holds off the [auto-stop timer](https://froogcorp.com/docs/sandboxes/#auto-stop). Turns may run up to `--timeout` seconds (default 3600, at most 21600). Ctrl+C stops following, not the agent: use `froog interrupt`.

**Steer and interrupt.** `froog steer my-box "skip the integration tests"` (API `POST /steer`) sends a message to the running turn without stopping it. Claude Code takes it in the middle of the turn (`native: true`, mode `native`). The other harnesses have no way to take a message mid-turn, so it is queued as the next turn of the same conversation (mode `queued`, with its `queued_prompt_id`). `late` means Claude Code's turn had just ended. With nothing running it is refused with `409 no_running_turn`. `froog interrupt my-box` (API `POST /interrupt`) stops every running turn and cancels the queued prompts on the sandbox. `--conversation ID` stops just one. Files the agent changed stay, and the conversation keeps its memory.

**Events.** `froog events my-box --follow` prints what every agent on the sandbox does as it happens (`--conversation ID` for one). Each event has an increasing `id`, its `prompt_id` and `conversation_id`, and a `type`: `status` (`data.status`, and `error` on failure), `text` (`content`), `tool_call` (`name`, `input`), `tool_result` (`output`, `is_error`), `steer`, `log` (a line the harness printed that isn't an event) and `error`. `GET /v1/sandboxes/{name}/events` and `/prompts/{id}/events` answer a JSON page (pass the last id as `cursor`), or keep streaming with `Accept: text/event-stream` (server-sent events, resumable with `Last-Event-ID`) or `Accept: application/x-ndjson`. A prompt's stream ends after its final status. Events are kept 90 days. With [monitoring off](https://froogcorp.com/docs/data/) only status changes are stored: the rest is streamed live and not kept.

**Attachments.** `--attach FILE` (repeatable, API `attachments: [{name, content_b64}]`) sends images, PDFs or any file: up to 12, 25 MB each, 64 MB in total. They are saved in the sandbox under `~/attachments/<prompt id>/` and their paths are added to the prompt, so every harness reads them from disk (Claude Code opens images and PDFs with its Read tool).

**From code.** `POST /v1/sandboxes/{name}/prompts` queues a prompt and answers `202` with it (`id`, `conversation_id`, `status`). Follow it with the events or poll `GET /prompts/{id}` until `done` is true (`result` holds the agent's final answer, `error` and `error_code` why it failed). `POST /v1/sandboxes/{name}/prompt` waits and answers `{exit_code, stdout, stderr, timed_out, prompt_id, conversation_id, status}`, with the final answer in `stdout`. Both take `prompt`, `agent`, `model`, `effort`, `fast`, `new`, `conversation_id`, `attachments` and `timeout_secs`. The SDKs have `sandbox.prompt(…)`, `queue_prompt`, `watch_prompt(id)`, `conversations()`, `steer(…)` and `interrupt()`.

### Subscriptions

Run Claude Code on a Claude Pro or Max plan and Codex on a ChatGPT plan instead of API keys. In the dashboard, open **Agents** and click **Sign in** (or `froog subscriptions login claude --sandbox my-box`). froog runs the vendor's own sign-in in one of your running sandboxes: `claude setup-token` shows a claude.ai link, and you paste back the code the page shows. `codex login --device-auth` shows a link and a one-time code to enter there. The result is stored encrypted like your other secrets: the Claude token as the variable `CLAUDE_CODE_OAUTH_TOKEN` (valid for a year), the ChatGPT login as the secret file `~/.codex/auth.json`. Every sandbox gets it at its next start. **Sign out** (`froog subscriptions logout claude`, API `DELETE /v1/subscriptions/{provider}`) removes it. The sign-in leaves nothing in the sandbox it ran in, and gives up after 15 minutes. froog shows only a link to the vendor's own hosts (`claude.ai`, `claude.com`, `console.anthropic.com`, `auth.openai.com`, `chatgpt.com`) and stores only what looks like the vendor's credential (a `sk-ant-oat…` token, a Codex `auth.json`). Anything else fails the sign-in. Connections and sign-outs are in the [audit log](https://froogcorp.com/docs/audit/).

Limits: the subscription is used only by the vendor's own CLI in your sandboxes, under the vendor's terms for your plan, and your plan's usage limits apply. Each sandbox gets its own copy of the ChatGPT login and Codex refreshes it there. If Codex reports the login expired, sign in again. Anyone with a shell in a sandbox can read these credentials, like any [variable](https://froogcorp.com/docs/env/): keep them out of sandboxes other people use (`--no-environment`). They are never [proxied](https://froogcorp.com/docs/ai-proxy/). API: `GET /v1/subscriptions`, `POST /v1/subscriptions/{claude|chatgpt}/login {sandbox}`, `GET …/login` (polls it and stores the result), `POST …/login/code {code}`.

### Computer use

Every agent in a sandbox can see and use its Linux desktop (the one `froog desktop` shows) through an MCP server named `computer`, registered at every start for every harness (Claude Code, Codex, Gemini CLI, OpenCode, pi, Prime Agent, Kimi Code, Mistral Vibe, Amp and Cursor):`froog prompt my-box "open example.com in Chrome and tell me the page title"`. Open `froog desktop` to watch it work.

| Look | `screenshot` (the desktop or one window), `get_window_state` (one window's screenshot plus its accessibility tree), `get_screen_size`, `list_windows` |
| --- | --- |
| Point and type | `click`, `double_click`, `right_click`, `move_cursor`, `drag`, `scroll`, `type_text`, `press_key` (e.g. `ctrl+l`), `hotkey` (e.g. `["ctrl", "s"]`), `set_value` |
| Apps and windows | `launch_app`, `kill_app`, `bring_to_front`, `set_window_frame`, `invoke_menu` (e.g. `File > Save As`), `close_window` |
| Browser | `browser_navigate`, `get_browser_state`, `browser_click`, `browser_type`, `browser_download` |
| Clipboard | `clipboard_read`, `clipboard_write` |

The desktop starts on the first tool call if it isn't running. All agents on a sandbox share one screen, so give parallel GUI tasks their own sandboxes. The server is `~/.froog/computer/mcp.py` (plain Python, stdio), with `cdp.py` and `a11y.py` next to it. froog writes the `computer` entry into each harness's config at every start and leaves your other MCP servers alone. For pi it's `mcpServers.computer` in `~/.pi/agent/mcp.json` with `"exposure": "direct"`, so its tools reach the model like built-in ones. An `exposure` you set there stays. Prime Agent (`mcpServers.computer` in `~/.prime/agent/settings.json`) reaches MCP servers from its Python kernel, not as tools of their own: its model calls `await mcp.call_tool("computer", "screenshot", {})`. For Kimi Code it's `~/.kimi-code/mcp.json`, and for Mistral Vibe an `[[mcp_servers]]` entry in `~/.vibe/config.toml` (its tools are named `computer_screenshot` and so on). A `computer` server you defined in Vibe's config yourself stays. Screenshots are large in a model's context: ask for one window, or use `get_browser_state` and `get_window_state`, which return text.

**The browser.** The browser tools drive Google Chrome (Chromium on Omarchy) on the desktop over the Chrome DevTools Protocol, on port 9222 of `127.0.0.1`. The first browser tool starts Chrome if it isn't open, with its own profile, `~/.config/froog-chrome`, so sign-ins stay through stops, snapshots and forks. (Chrome opens the port only for a profile other than its default one.) `get_browser_state` gives the page's URL, title, open tabs and its links, buttons and fields, each with a number. `browser_click` and `browser_type` take that number, or a CSS selector, or the element's visible text. The numbers change with each `get_browser_state`. Clicks and typing go into the page itself, so they work while Chrome's window is behind another one. `browser_download` fetches a URL, or the file a link or button starts, with the browser's cookies, saves it to `~/Downloads` (or `directory`) and returns the path. It waits up to `timeout_seconds` (default 120). The port can't be reached from outside the sandbox, but any program in the sandbox can use it, and with it the profile's sign-ins.

**Accessibility tree.** `get_window_state` returns a window's screenshot and its accessibility tree (AT-SPI): each element's role, name, value, state and position, with a ref like `0.3.1`. Without a window it uses the one with focus. `screenshot: false` leaves the picture out, and `max_nodes` (default 300) caps the list. `click` with a `ref`, `set_value` (a text field, slider, spin box or list item) and `invoke_menu` act through accessibility, so they need no focus and no coordinates. GTK apps (most of XFCE), Qt apps and LibreOffice have a tree. Chrome and Electron apps have one only when started with `--force-renderer-accessibility` (for web pages, use the browser tools), and Wine apps have none. A program started before the desktop had accessibility on (desktops started before this release) has none until it is restarted. On Ubuntu and Debian a window's screenshot brings the window to the front.

**Input without focus.** By default, clicks and keys go to the screen like a person's, to whatever window is in front. Give `click`, `double_click`, `right_click`, `scroll`, `type_text`, `press_key` or `hotkey` a `window` and that window is brought to the front for the action, and the window that had focus gets it back afterwards. Add `"delivery_mode": "background"` and the events go to that window directly (X11 synthetic events), without raising or focusing it and without moving the pointer. Many programs ignore synthetic events: Chrome and Firefox, GTK apps (Mousepad, tested) and terminals (XFCE Terminal, tested, xterm unless told otherwise). Classic X programs take them (xedit, tested). The tool can't tell, so check with a screenshot. Omarchy (Wayland) has no background input: the tools answer with an error. Two focus-free ways always work: the browser tools for web pages, and refs from `get_window_state`.

`set_window_frame` moves and resizes a window, in screen pixels (on Omarchy it floats the window first, and says so when Hyprland didn't place it). `kill_app` stops a program by `pid`, by `window` or by its exact process `name`: SIGTERM, then SIGKILL after `grace_seconds` (default 3). It stops only the sandbox user's programs, and never the agent that calls it.

### Customize the agents

Every harness reads its own config from the sandbox's home directory, and `froog prompt` runs from `~`. froog adds three things: the `froog` skill (what the machine is, how to run servers and long jobs) in each harness's skills folder, a short system prompt for Claude Code, pi and Prime Agent saying they run headless in a sandbox, and the `computer` MCP server (above). You add the rest:

| Harness | House rules | MCP servers | Skills |
| --- | --- | --- | --- |
| Claude Code | `~/CLAUDE.md`, `~/.claude/CLAUDE.md` | `claude mcp add --scope user NAME -- CMD` | `~/.claude/skills/NAME/SKILL.md` |
| Codex | `~/AGENTS.md`, `~/.codex/AGENTS.md` | `codex mcp add NAME -- CMD` | `~/.codex/skills/` |
| Gemini CLI | `~/GEMINI.md`, `~/.gemini/GEMINI.md` | `mcpServers` in `~/.gemini/settings.json` | `~/.gemini/skills/`, `~/.agents/skills/` |
| OpenCode | `~/AGENTS.md`, `~/.config/opencode/AGENTS.md` | `mcp` in `~/.config/opencode/opencode.json` | `~/.config/opencode/skills/` |
| pi | `~/AGENTS.md`, `~/.pi/agent/AGENTS.md` | `pi mcp add NAME -- CMD`, or `mcpServers` in `~/.pi/agent/mcp.json` | `~/.pi/agent/skills/` |
| Prime Agent | `~/AGENTS.md`, `~/.prime/agent/AGENTS.md` | `prime-agent mcp add NAME -- CMD`, or `mcpServers` in `~/.prime/agent/settings.json` | `~/.prime/agent/skills/` |
| Kimi Code | `~/AGENTS.md`, `~/.kimi-code/AGENTS.md` | `~/.kimi-code/mcp.json` | `~/.kimi-code/skills/`, `~/.agents/skills/` |
| Mistral Vibe | `AGENTS.md` in a trusted git repository | `[[mcp_servers]]` in `~/.vibe/config.toml` | `~/.vibe/skills/`, `~/.agents/skills/` |
| Amp, Cursor | `~/AGENTS.md` | see their docs | `~/.agents/skills/` (Amp) |

One `~/AGENTS.md` plus one `~/CLAUDE.md` covers almost every harness. To give every new sandbox the same rules, put them in your account's [secret files](https://froogcorp.com/docs/environment/), and register MCP servers in the setup script:

```sh
$ froog environment add-file AGENTS.md ./house-rules.md
$ froog environment add-file CLAUDE.md ./house-rules.md
```

A sandbox has no route back to your computer. `froog forward NAME PORT --reverse` opens one over SSH: a port on your computer answers at `127.0.0.1:PORT` inside the sandbox while the command runs (`LOCAL:SANDBOX` to use another port). Use it for an MCP server or a local model:

```sh
$ froog forward my-box 7777 --reverse     # leave running: your MCP server on localhost:7777
$ froog exec my-box -- 'claude mcp add --scope user mine --transport http http://127.0.0.1:7777/mcp'
$ froog forward my-box 11434 --reverse    # Ollama, for any harness that takes a base URL
```

`--bind ADDR` with `--reverse` sets the address the port listens on inside the sandbox (default `127.0.0.1`, anything else needs `GatewayPorts clientspecified` in the sandbox's `/etc/ssh/sshd_config`). Both directions reconnect when the tunnel drops, for example after `froog stop` and `froog start` bring the sandbox back on a new address: `froog forward` asks the API for the current address and port, says so when they changed, and tries up to 5 times in a row (waiting 2 to 30 seconds between tries) before it gives up. A failure in the first 5 seconds, like a local port already in use, is reported at once and not retried.

You can also run your agent on your own machine and give it the sandbox as a tool: `froog exec` and the HTTP API return stdout, stderr and the exit code.

## Environment variables

Variables you set on your account are in every sandbox you start: in shells, `froog exec`, background commands and agent prompts.

```sh
$ froog env set ANTHROPIC_API_KEY             # prompts for the value
$ echo "$DATABASE_URL" | froog env set DATABASE_URL
$ froog env list                             # names and masked previews
$ froog env rm DATABASE_URL
```

Values are encrypted at rest and never shown again, in the CLI, the API or the [dashboard](https://froogcorp.com/dashboard/). A sandbox gets the variables when it starts, so changes reach running sandboxes on their next start. They're written to `~/.froog/env` and loaded by login shells. Services you run with systemd don't load it on their own. Up to 50 variables, 16 KiB in total.

### A sandbox's own variables

Give one sandbox variables of its own with `froog new NAME -e KEY=VALUE` (repeat it, and `-e KEY` copies it from your shell), or `"env": {"KEY": "VALUE"}` over the API. They come on top of your account's (and win), and they stay with `--no-environment`: that's how a platform gives each user's sandbox the user's own keys and nothing of yours. Forks keep them (`froog fork box box-2 -e KEY=VALUE` adds or overrides some), and a sandbox from a saved snapshot gets its source's unless you send your own. Names show in `froog info`, but values are never returned.

## AI credentials & proxy

Give your sandboxes AI keys from a **credential profile**: a named set of provider keys and custom APIs, with several accounts per provider. Turn on the **AI proxy** and a sandbox gets placeholder keys instead of the real ones: the real keys stay encrypted on froog, which forwards the agents' requests. The proxy is off by default. Nothing changes until you turn it on.

```sh
$ echo "$ANTHROPIC_API_KEY" | froog ai key-add anthropic       # into the default profile
$ echo "$ANTHROPIC_KEY_2" | froog ai key-add anthropic         # a second account, used when the first runs out
$ froog ai proxy on                                            # sandboxes started from now on get placeholders
$ froog ai usage                                               # requests, tokens and estimated cost per sandbox
$ froog ai block my-box                                        # refuse its AI requests at once
```

### Credential profiles

Every account has a default profile, named `default`. It is made the first time you list or add to it. A sandbox gets the profile its [environment](https://froogcorp.com/docs/environment/#environments) names (`froog ai env ci ci-bots`, API `PUT /v1/ai/environments/{env} {"profile"}`, `null` means the default), or the default. `froog ai use my-box ci-bots` (API `PUT /v1/sandboxes/{name}/ai {"profile"}`, `""` goes back to the environment's) gives one sandbox another one: proxied requests use it at once, the variables change at the next start. The API never returns a key: a profile lists each account's provider, its last four characters (`…7f3a`) and its id.

```sh
$ froog ai profile-new ci-bots --copy-from default
$ echo "$OPENAI_API_KEY" | froog ai key-add openai --profile ci-bots
$ echo "$STRIPE_KEY" | froog ai key-add STRIPE_KEY --base-url https://api.stripe.com --profile ci-bots
$ echo "$ACME_KEY" | froog ai key-add ACME_KEY --base-url https://api.acme.dev --secret-header X-Api-Key
$ froog ai profiles
$ froog ai key-rm cred_0123456789abcdef --profile ci-bots
$ froog ai profile-set ci-bots --default
```

| Provider | The sandbox gets | Proxied to |
| --- | --- | --- |
| `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL` | api.anthropic.com (Messages, streaming included) |
| `openai` | `OPENAI_API_KEY`, `OPENAI_BASE_URL` (`…/openai/v1`) | api.openai.com (Chat Completions, Responses, embeddings, models) |
| `gemini` | `GEMINI_API_KEY`, `GOOGLE_GEMINI_BASE_URL` | generativelanguage.googleapis.com |
| `deepseek`, `mistral` | `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`. `MISTRAL_API_KEY`, `MISTRAL_BASE_URL` | api.deepseek.com, api.mistral.ai |
| `openrouter`, `xai` | `OPENROUTER_API_KEY`, `OPENROUTER_BASE_URL`. `XAI_API_KEY`, `XAI_BASE_URL` (both `…/v1`) | openrouter.ai/api, api.x.ai |
| `bedrock` | `AWS_BEARER_TOKEN_BEDROCK`, `ANTHROPIC_BEDROCK_BASE_URL`, `AWS_REGION`, and `CLAUDE_CODE_USE_BEDROCK=1` when there's no Anthropic key | bedrock-runtime.REGION.amazonaws.com, signed with SigV4 |
| a custom API | `NAME`, `NAME_BASE_URL` | its base URL |

A key is one token of up to 8192 printable characters, without spaces or line breaks (`400` otherwise). The CLI reads it from stdin when you leave it out, so it stays out of your shell history. Bedrock takes a Bedrock API key, or AWS keys as `ACCESS_KEY_ID:SECRET_ACCESS_KEY[:SESSION_TOKEN]`, and `--region` (default `us-east-1`). A custom API is any HTTPS API, OpenAI-compatible ones included: its name is A-Z, 0-9 and `_`, up to 64 characters. Provider names (`ANTHROPIC_*`, `OPENAI_*`, `AWS_*`, `FROOG_*`, …) and names ending in `_BASE_URL` are reserved (`400 reserved_name`). The base URL must be `https://` on the default port with a public DNS name. You also say where its secret goes, and the proxy puts it only there: a header, `Authorization` with `Bearer ` unless you say otherwise (`--secret-header X-Api-Key`, `--secret-prefix "Token "`, API `secret_header`, `secret_prefix`), or a query parameter (`--secret-query api_key`, API `secret_query`). Headers the transport or the browser owns (`Host`, `Cookie`, `Content-Type`, `Origin`, …) can't hold it (`400`). The dashboard takes it as `Authorization: Bearer`, `X-Api-Key` or `?api_key`. Up to 20 profiles and 50 keys per profile. A profile can't be deleted while it's the default (`409 is_default`) or an environment names it (`409 in_use`). Without the proxy, a sandbox gets the real key of the account in use, and a custom API's variables hold its secret and its own base URL.

A provider key you set with [`froog env`](https://froogcorp.com/docs/env/) (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, …) still works: it counts as the last account of its provider in every profile, so turning the proxy on keeps it out of sandboxes too. One whose base URL variable you also set yourself (`ANTHROPIC_BASE_URL`) passes unchanged: add that API as a custom API to proxy it. A sandbox's own variables (`-e OPENAI_API_KEY=…`) always win and are never proxied, and a sandbox started with `--no-environment` gets no AI credentials at all.

### Several accounts per provider

Add a second key for a provider and it becomes its second account. The proxy uses the first account that isn't resting. When an answer says the account hit a limit, the proxy rests that account and sends the same request to the next one, at once: a `429` rests it for its `Retry-After` (default 60 seconds), and a `402` or a quota error code in the provider's JSON error (`insufficient_quota`, `billing_hard_limit_reached`, `billing_not_active`, `credit_balance_too_low`, …) for an hour. The words of an error message never count (a prompt can put them there): an Anthropic "credit balance is too low" error rests nothing, so remove or switch that account yourself. When every account is resting, the one back soonest is tried. `froog ai profiles` shows which account is in use and which rest, and why. `froog ai switch cred_…` (API `PUT /v1/ai/profiles/{profile}/credentials/{id}/active`) puts an account first and ends its rest. Switching works only through the proxy: without it, a sandbox gets the first account's key at its start.

### The proxy

```sh
 sandbox                                  froog                                 provider
 ANTHROPIC_API_KEY=froog-ph-…
 ANTHROPIC_BASE_URL=https://ai.froogcorp.com/anthropic
 agent ── request + placeholder ──▶  placeholder of a running sandbox?
                                      blocked?  swap in the real key ── request ──▶  api.anthropic.com
       ◀────── answer, streamed ────  count the tokens ◀──────────── answer ──────
```

`froog ai proxy on` (API `PUT /v1/ai/proxy {"enabled": true}`, an `account.admin` key) applies to sandboxes at their next start. Stop and start a running one to switch it now. `froog ai proxy` shows the state and the proxy's URL.

- The placeholder is a secret of its own for each sandbox, worth nothing outside froog. It stops working when the sandbox stops or is deleted, and each start (a warm resume too, which rewrites `~/.froog/env`) gives new ones: a process that kept the old one gets `401 ai_placeholder_invalid` and must be restarted.

- The proxy sends each key only to its own provider, and only in that provider's key header (`x-api-key`, `Authorization`, `x-goog-api-key`, SigV4 for AWS keys). A custom API gets its secret only in the header or query parameter its credential names. The placeholder anywhere else in the request (another header, the query string) is removed, never swapped, and a request with it in the path is refused (`400 ai_placeholder_in_path`). An answer header that holds a secret is dropped. Still, pick a base URL you trust: it sees the secret.

- A built-in provider takes only its model endpoints. Anything else is `403 ai_path_not_allowed`. Anthropic: `/v1/messages`, `/v1/messages/count_tokens`, `/v1/models`. OpenAI: `/v1/chat/completions`, `/v1/responses` (and below), `/v1/embeddings`, `/v1/models`. Gemini (`v1beta`, `v1`, `v1alpha`): `models/MODEL:generateContent`, `:streamGenerateContent`, `:countTokens`, `:embedContent`, `:batchEmbedContents`, and `/models`. Bedrock: `/model/MODEL/invoke`, `invoke-with-response-stream`, `converse`, `converse-stream`. DeepSeek, Mistral, OpenRouter and xAI: `chat/completions`, `completions`, `embeddings` and `models`, with or without `/v1`. A custom API takes any path. No path may hold `.` or `..` segments.

- Answers stream back as they arrive (server-sent events included). Nothing is buffered. Requests are limited to 16 MiB (`413 request_too_large`), and the whole body must arrive within 60 seconds (`408 request_timeout`). At most 16 requests at once per sandbox and 64 per account, answers included until they end (`429 ai_too_many_requests`, `Retry-After: 1`). The API's per-address limit applies, and the same number per account (`429 ip_rate_limited`, `account_rate_limited`). No redirects are followed. A custom API must resolve to a public address.

- Answers are the provider's, but nothing in them runs in a browser: only the headers an SDK reads come back (`Content-Type`, `Content-Length`, `Content-Encoding`, `Retry-After`, request ids, rate limit headers, and the providers' own `anthropic-*`, `openai-*`, `x-ratelimit-*`, `x-amzn-*`, `x-goog-*`), every answer carries `Content-Security-Policy: sandbox; default-src 'none'` and `X-Content-Type-Options: nosniff`, and a type other than JSON, server-sent events, NDJSON, plain text or bytes becomes `application/octet-stream`. The proxy answers only at its own address (`ai.froogcorp.com`, never `api.froogcorp.com/ai`), and refuses a browser page or navigation, a froog session cookie and the dashboard's origin (`403 ai_browser_refused`).

- froog never logs prompts, answers or keys: only the provider, the status and the sandbox.

- Subscription sign-ins are not proxied: the Claude token (`CLAUDE_CODE_OAUTH_TOKEN`) and the ChatGPT login (`~/.codex/auth.json`) pass as they do without the proxy, because the agents renew and present them themselves. `froog ai proxy on --withhold-subscriptions true` keeps them out of proxied sandboxes. The agents then use the proxied keys. `froog prompt` and the agents work the same either way.

Proxy errors are JSON `{"error": {"code", "message"}}`: `400 ai_placeholder_in_path`, `401 ai_placeholder_missing` or `ai_placeholder_invalid`, `403 ai_blocked`, `ai_path_not_allowed` or `ai_browser_refused`, `404 ai_no_credential` (the profile has no key for that provider) or `ai_unknown_provider`, `408 request_timeout`, `413 request_too_large`, `429 ai_too_many_requests`, `ip_rate_limited` or `account_rate_limited`, `502 ai_upstream_unreachable`. Everything else is the provider's own answer.

### Usage

For each proxied sandbox, froog counts requests and errors and the tokens per UTC day, provider and model, as the provider reports them in its answers (streamed ones included): input not read from a cache, output, cache reads and cache writes. `cost_usd` is an estimate at list prices built into froog, not a froog charge and not your provider's bill. Unknown models count tokens at $0. A sandbox that holds real keys has no usage here. Usage is kept 400 days.

```sh
$ froog ai usage --days 7          # every sandbox, most expensive first (GET /v1/ai/usage?days=7)
$ froog ai usage my-box            # per day and model (GET /v1/sandboxes/my-box/ai)
```

### Block a sandbox

`froog ai block my-box` (API `PUT /v1/sandboxes/{name}/ai {"blocked": true}`) refuses the sandbox's AI requests at once with `403 ai_blocked`, running or stopped. `froog ai unblock my-box` accepts them again. Anyone who may change the sandbox may block it (`sandbox.update`, the in-sandbox key and organization members included). Unblocking it, or giving it another profile, needs a key with `environment.write` or `account.admin` that isn't limited to some sandboxes (so never the in-sandbox key: a blocked sandbox can't unblock itself, `403 api_key_limited` or `api_key_action_forbidden`), and an owner or admin in an organization (`403 org_role_forbidden`). `--all` (API `PUT /v1/ai/proxy {"blocked": true}`) blocks every proxied sandbox of the account. A block has no effect on a sandbox that holds real keys. froog has no automatic spend cap: read `GET /v1/sandboxes/{name}/ai` on a schedule and block a sandbox past your limit.

API: `GET`/`PUT /v1/ai/proxy`, `GET`/`POST /v1/ai/profiles`, `GET`/`PATCH`/`DELETE /v1/ai/profiles/{profile}`, `POST /v1/ai/profiles/{profile}/credentials {provider, value, name?, base_url?, region?, secret_header?, secret_prefix?, secret_query?}`, `DELETE …/credentials/{id}`, `PUT …/credentials/{id}/active`, `PUT /v1/ai/environments/{env}`, `GET /v1/ai/usage`, `GET`/`PUT /v1/sandboxes/{name}/ai`. Profiles need `environment.read` or `environment.write`, the proxy switch `account.admin`, account usage `account.read`, a sandbox's AI `sandbox.read` or `sandbox.update` (unblocking and another profile: see above). Changes are in the [audit log](https://froogcorp.com/docs/audit/) (`ai.proxy`, `ai.credential_add`, …), never with a key. The SDKs have them as `getAiProxy`, `addAiCredential`, `getAiUsage`, `updateSandboxAi` and so on.

## Repos, secret files & setup

Besides [variables](https://froogcorp.com/docs/env/), your account's environment says what every new sandbox starts with: GitHub repos cloned into `~`, secret files such as `.npmrc` or `app/.env`, and a setup script.

```sh
$ froog environment add-repo https://github.com/you/app        # optionally #branch
$ froog environment add-file app/.env ./app/.env              # or pipe it on stdin
$ froog environment add-file .npmrc ~/.npmrc
$ echo 'npm ci' | froog environment repo-setup app -         # runs in ~/app after the clone
$ echo 'docker compose up -d' | froog environment setup -
$ froog environment show
```

At every start (create, start, fork, restore), before the sandbox counts as running, the secret files are written, mode 600. Then a background command clones each repo whose directory has no checkout yet, runs each repo's own setup script in its directory (`froog environment repo-setup REPO FILE`, REPO being its directory or URL, `--clear` removes it), and then the setup script from `~`, all in login shells. A secret file inside a repo directory, like `app/.env`, stays when the repo is cloned around it. The setup script runs at every start, so make it safe to run again. Its output is a [background command](https://froogcorp.com/docs/jobs/): `froog jobs box`, then `froog logs box JOB`. Setup doesn't hold off auto-stop. `froog info box` and the API's `setup_status` say how the last start's setup went: `none` (nothing to do), `running`, `done` or `failed`, with `setup_error` saying why (a repo that didn't clone, a script's exit code). A sandbox stopped mid-setup shows `failed`.

**Private repos:** [connect GitHub](https://froogcorp.com/docs/github/) (each sandbox then gets a short-lived token), or set a `GH_TOKEN` variable (setup runs `gh auth setup-git`, so `git` and `gh` both use it), or add a `.git-credentials` secret file. URLs can be `https://`, `ssh://` or `git@host:org/repo`. SSH URLs need a key you add as a secret file (e.g. `.ssh/id_ed25519`).

**One sandbox's files:** `froog new box --file app/.env=.env` (repeat it) writes a local file into `~` before the sandbox counts as running, after your secret files, so it wins over one with the same path. Mode 600, up to 20 files and 1 MiB together, kept encrypted until the first boot and then forgotten. Forks don't get them again (the disk already has them).

**One sandbox's repo:** `froog new pr-42 --repo https://github.com/you/app#pr-42` clones that repo and branch into `~/app` of this sandbox only (`--repo-dir src/app` puts it elsewhere under `~`). API: `"repo": {"url", "branch"?, "dir"?}` on create. It's cloned by the same setup job as your account's repos, first, and also with `--no-environment`. Private repos need credentials in the sandbox: `-e GH_TOKEN=…`, or your environment's. Don't put a token in the URL (it's refused, since the URL shows in `froog info`). Forks don't clone it again (the disk already has it).

**One-off setup:** `froog new box --setup-file setup.sh` runs a script once, after the first boot, after the environment's own setup. Forks don't rerun it.

**Nothing of yours:** `froog new box --no-environment` starts a sandbox without your variables, repos, secret files, setup scripts or in-sandbox `froog` key. Use it for code or people you don't trust with your credentials. Its forks get none either.

**Removing your logins.** A disk that had your environment still holds what it wrote and what you did there. So when a sandbox starts without the environment from such a disk (`froog fork box copy --no-environment`, `froog start box --no-environment`, or `froog new copy --from SAVED --no-environment`), and whenever a sandbox starts from a [share code](https://froogcorp.com/docs/snapshots/#share), froog first removes these from its disk, before it counts as running. If that fails, the start fails (`error`) rather than open the sandbox with them.

- git and GitHub: `~/.config/gh`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.config/hub`, and every `credential` section of `~/.gitconfig`.

- SSH: everything in `~/.ssh` but `authorized_keys` and `known_hosts*` (keys under any name, `config`, `config.d`). `authorized_keys` stays: froog writes the keys allowed in at every boot (yours, or for a share code the other account's) and its own control key.

- Coding agents: `~/.claude/.credentials.json` and the `oauthAccount`, `primaryApiKey` and `customApiKeyResponses` keys of `~/.claude.json` (the rest of it stays), `~/.codex/auth.json`, Kimi Code's `~/.kimi-code/credentials/kimi-code.json` and `config.toml`, Mistral Vibe's `~/.vibe/.env`, `~/.gemini/oauth_creds.json` and `google_accounts.json`, `~/.local/share/opencode/auth.json`, `~/.local/share/amp/secrets.json`, `~/.pi/agent/auth.json`, Cursor's `~/.cursor/cli-config.json` and `~/.config/cursor/auth.json`, `~/.config/github-copilot`, `~/.aider.conf.yml`.

- Registries and containers: `~/.npmrc`, `~/.yarnrc.yml`, `~/.pypirc`, `~/.netrc`, `~/.cargo/credentials` and `credentials.toml`, `~/.gem/credentials`, `~/.docker/config.json`.

- Clouds, secrets managers and model hubs: `~/.aws/credentials`, `~/.aws/sso/cache`, gcloud's `credentials.db`, `access_tokens.db`, `legacy_credentials` and `application_default_credentials.json`, Azure's token caches, all of `~/.kube`, `~/.vault-token`, `~/.terraform.d/credentials.tfrc.json`, 1Password's `~/.config/op`, Hugging Face's `~/.cache/huggingface/token` and `~/.huggingface/token`.

- froog's in-sandbox key (`~/.config/froog/config.json`, which reached the source sandbox), the variables file `~/.froog/env` (the new boot writes its own), and shell and REPL history (`~/.bash_history`, `~/.zsh_history`, fish's `~/.local/share/fish/fish_history`, `~/.python_history`, `~/.node_repl_history`, `~/.psql_history`, `~/.mysql_history`, `~/.sqlite_history`, `~/.rediscli_history`).

- All of the above under `/root` too, plus `/root/.claude`, `/root/.claude.json` and `/root/.aws` whole.

- Every secret file any version of any of your environments has had, and the setup inputs in `~/.froog/setup`. For a share code also the directories of the repos your environments clone.

- cloud-init's copies of earlier boots' settings (`/var/lib/cloud/instances/*` but the current boot's) and its logs (`/var/log/cloud-init*.log`).

- System logs, which can hold secrets (sudo command lines, programs' output): the journal is rotated and its archives deleted, rotated logs are deleted, and the rest of `/var/log` is emptied.

Files are overwritten before they're removed (up to 64 MiB each), and the scrub ends with `fstrim`, which hands the disk's free space back to the host: whoever gets root in the sandbox can't read removed files back off the raw disk, nor anything deleted on it earlier. If `fstrim` fails, the start fails. The boot also gets a new SSH host key (the API's `ssh` answer shows it), so the disk's earlier user can't pose as this sandbox.

Your variables never reach such a sandbox: froog writes `~/.froog/env` over SSH at every boot, before the sandbox counts as running, and never puts it in the boot settings cloud-init keeps on the disk. Later starts of the same sandbox remove nothing again: logins made in it are its user's. Forks of it need nothing removed. Removing a file deletes it, so keep anything you need from such a disk elsewhere first.

### Several environments

Your account starts with one environment, `default`, which `froog environment show` and `/v1/environment` work on. Make more for different kinds of sandboxes (a staging stack, CI jobs, users' sandboxes) and pick one per sandbox.

```sh
$ froog environment create ci --copy-from default    # or empty
$ froog environment add-file .npmrc ./ci.npmrc --env ci
$ froog new job-1 --environment ci
$ froog environment list                             # default marker, latest version, sandboxes per version
$ froog environment show ci
$ froog environment default ci                       # what new sandboxes use when they name none
$ froog environment rename ci jobs
$ froog environment rm jobs
```

- `add-repo`, `rm-repo`, `repo-setup`, `setup`, `add-file` and `rm-file` take `--env NAME` (default: your default environment). Names are 1–40 lowercase letters, digits and dashes. Up to 20 environments.

- Account variables (`froog env`) go to every environment. A sandbox's own variables (`-e`) come on top.

- `froog info` and the API's `environment_name` say which one a sandbox is in. A sandbox with `--no-environment` is still in one, and gets none of it.

- Deleting an environment keeps its sandboxes running on their version. New sandboxes can't use it. The default can't be deleted (`409 environment_is_default`): make another the default first.

- API: `GET · POST /environments`, `GET · PATCH · DELETE /environments/{env}`, `PUT · DELETE /environments/{env}/files`. Create with `environment_name` on `POST /sandboxes`.

### Versions and upgrades

Every change to an environment (repos, scripts, secret files) makes a new version, and versions never change. A sandbox stays on the version it was created with, at every start, until you move it:

```sh
$ froog environment versions ci           # v3 "file .npmrc set", v2 "repos", …, with sandboxes on each
$ froog environment upgrade ci            # every sandbox of ci to the latest (also: froog env upgrade ci)
$ froog environment upgrade ci --sandbox job-1
$ froog start job-2 --environment default # onto another environment's latest version
```

- An upgrade applies at once to running sandboxes: it writes the new secret files, removes the ones the new version dropped, and runs the setup job again. Stopped ones get it at their next start. `POST /environments/{env}/upgrade` answers `{version, applied, at_next_start}`.

- A fork gets exactly its source's environment and version (`--environment NAME`: that one's latest). A sandbox from a saved snapshot gets the latest version of the environment it names.

- Sandboxes created before environments had versions join the latest version at their next start.

- Removing a file in a new version deletes it from a sandbox's disk when the sandbox moves to that version. Going back to an older version doesn't bring a deleted file back until the next apply writes it.

- The last 50 versions are kept, and any older one a sandbox is still on.

Secret file contents and the setup scripts are encrypted at rest, and file contents are never shown again. Up to 20 repos, 50 files (64 KiB each, 1 MiB in total), and 64 KiB per script. The [dashboard](https://froogcorp.com/dashboard/) edits all of it too.

## GitHub

Connect GitHub to clone private repositories without a pasted token. froog uses a GitHub App: you pick the account or organization and the repositories, and each sandbox gets a token that works for one hour, only for those repositories.

```sh
$ froog github connect                      # opens GitHub: install the app, pick repositories, authorize
$ froog github                              # linked accounts, and what the sandbox token reaches
$ froog github repos                        # repositories the app can see
$ froog github token-repos acme/api acme/web  # limit the sandbox token to these (none: every repository the app sees)
$ froog environment add-repo https://github.com/acme/api
$ froog github disconnect 4242              # unlink an installation (its id from froog github)
```

GitHub asks you to authorize the app when you install it. froog uses that only to check that you manage the installation (your own GitHub account, or an organization you're an owner of), so nobody can link an installation that isn't theirs. The connect link (`froog github connect`, or the dashboard's Connect button) must be opened in a browser signed in to the dashboard with the same account. Otherwise it sends you to the dashboard with an error. An install links only in the browser that started it: a GitHub callback link opened anywhere else links nothing. Uninstalling the app on GitHub unlinks it. The dashboard's Environment page has a repository picker that adds repositories from the list.

**In each sandbox.** A sandbox with your environment gets a git credential helper for `github.com` and a `gh` login. `git clone`, `git push` and `gh` work with no setup, including the environment's repos at boot. The helper asks the API for a fresh token with the sandbox's own key (`POST /v1/sandboxes/{name}/github-token`, action `exec`) when the cached one has less than 5 minutes left, so a sandbox that runs for days keeps working. The `gh` login holds the token from the last start: run `git credential fill` or start the sandbox again for a new one. A token is an installation token on your token repositories with contents, pull requests, issues and workflows (read and write) and metadata (read), as far as the app has them. It never has Administration: it can't change repository settings or register runners. With several linked accounts, git gets the token of the repository's owner. froog keeps tokens encrypted and reuses one until 10 minutes before it expires. A sandbox created with `--no-environment`, and every [CI job](https://froogcorp.com/docs/ci/), gets no GitHub token.

API: `GET /github`, `POST /github/connect` (`{url}`), `DELETE /github/installations/{id}`, `GET /github/repos`, `PUT /github/token-repos {repos}`. Reading needs `account.read`, changes need `account.admin`. Without a GitHub App on the server, these answer `404 github_not_configured`, and a call for an owner with no installation answers `404 github_not_connected`.

## Pushbox: CI

[froog Pushbox](https://froogcorp.com/pushbox/) runs your CI two ways:

| Way | Setup | Use it when |
| --- | --- | --- |
| [GitHub Actions runners](https://froogcorp.com/docs/ci/#ci-runners) | Connect GitHub, change `runs-on` | Your CI is GitHub Actions |
| [The CLI from any CI](https://froogcorp.com/docs/ci/#ci-cli) | An API key and `froog run` | GitLab CI, CircleCI, Buildkite, or one step of a GitHub job |

### GitHub Actions runners: `runs-on: froog`

Each job gets a fresh sandbox, deleted when the job ends. CI is billed for job time only: $0.0006 per vCPU per minute (2 vCPU $0.0012, 4 vCPU $0.0024, 8 vCPU $0.0048, 16 vCPU $0.0096 a minute), per second, from when GitHub starts the job until it completes. Booting the VM, setting up the runner, saving a cache and a kept `/debug` VM aren't billed. [Connect GitHub](https://froogcorp.com/docs/github/) with the repositories, then change `runs-on`:

```sh
jobs:
  test:
    runs-on: froog          # was: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test
```

| Label | Sandbox |
| --- | --- |
| `froog-small` | `small`: 2 vCPU, 4 GB |
| `froog` | `default`: 4 vCPU, 8 GB |
| `froog-large` | `large`: 8 vCPU, 16 GB |
| `froog-xlarge` | `xlarge`: 16 vCPU, 32 GB (on the plans that have [xlarge](https://froogcorp.com/docs/pricing/)) |

The same sizes by vCPU count: `froog-2vcpu`, `froog-4vcpu`, `froog-8vcpu`, `froog-16vcpu`.

Add options after the size with `/`, for example `runs-on: froog-large/from=ci-base/debug`. `from=NAME` starts the job from a [saved snapshot](https://froogcorp.com/docs/snapshots/) with your toolchain in place. `debug` keeps the sandbox 30 minutes after a failed job, so you can `froog ci ssh` into it. Every job's log on GitHub shows the VM it runs on and the command to look inside while it runs (`froog ssh ci-…`, or `froog ci ssh ci_…`). `cache=NAME` keeps a warm disk between jobs: the job starts from the saved snapshot `cache-NAME` when there is one (otherwise from `from=` or the base image), and when a job of a push, a schedule or a manual run on the repository's default branch succeeds, its disk is saved back as `cache-NAME`. Pull request jobs read the cache and never write it. Pull requests from forks don't read it either, since their authors get none of the repository's secrets. The disk is kept, so toolchains, `~/.cargo`, `node_modules` and Docker layers are there next time. Keep build outputs inside the checkout with `clean: false` on `actions/checkout` (it deletes untracked files by default). Before saving, froog removes what later jobs mustn't read: logins in `~/.docker/config.json`, `~/.git-credentials`, `~/.config/gh`, `~/.npmrc`, `~/.pypirc`, `~/.netrc`, `~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, cargo and gem credentials and SSH private keys (root's too), shell histories, the runner's `_temp` (step scripts with secrets filled in, env and output files) and logs, the token `actions/checkout` leaves in `.git/config`, and `/tmp`. An executable `~/.froog/cache-scrub` runs first, for anything else your jobs write. If it or the cleanup fails, nothing is saved. Don't use `/cache` on jobs that use deployment secrets. A cache is a saved snapshot: it counts toward your saved snapshots, and `froog snapshots --rm cache-NAME` starts it over. A label with an unknown size or option never runs: froog refuses the job and says why.

How it works: GitHub sends froog a `workflow_job` event. froog creates a sandbox named `ci-<repo>-<job id>` (label `froog.ci=true`), asks GitHub for a just-in-time runner configuration, and the sandbox runs the GitHub Actions runner with it. The runner is ephemeral: it takes one job and exits. When GitHub reports the job completed, froog deletes the sandbox. Any CI sandbox still there 6 hours 15 minutes after it was created is deleted (GitHub's longest job is 6 hours). The job runs in `~/actions-runner/_work` on a full VM with Docker, so `services:`, `container:` and `docker build` work.

- **Limits and billing.** CI sandboxes count against your sandboxes-at-once cap and [start limits](https://froogcorp.com/docs/limits/). Their job time comes out of your plan's usage like sandbox time, and shows in `froog usage` and the dashboard as CI jobs. At a cap or a start limit a job waits, and froog tries again every 15 seconds. A job that still waits after 24 hours fails.

- **Refused jobs** show the reason: an unknown label, a public repository, a `from=` snapshot that doesn't exist, a sandbox that can't be created. A refused job of a public repository cancels its workflow run when the GitHub App has Actions: Read and write (otherwise the run stays queued until GitHub gives up).

- **Which jobs a runner takes.** GitHub registers a just-in-time runner on the whole repository, so it can take any queued job with the same labels. In a private repository that's fine: when two runs are queued at once, a job may run on the VM started for the other one, and that job's VM takes this one. In a public repository froog's runner has a job-started hook that fails any job that isn't from the run it was started for, before any step runs, so it never takes a job froog refused. The token that registers the runner (Administration, on that one repository) stays on froog's side. The sandbox gets only the runner configuration.

- **When something goes wrong.** A VM that doesn't start, or a runner that exits before taking its job, gets one more try on a new VM. After that the job fails with the reason (`froog ci job`). GitHub doesn't resend a webhook that failed, so froog asks it to (for the last day's events). A job still starting or running after 10 minutes is looked up on GitHub, so a lost event never leaves a VM running. A job still queued on GitHub whose VM went to another job of the repository gets a new VM, and runners GitHub still lists for VMs that are gone are removed from the repository every 10 minutes (only the ones froog named). The runner is GitHub's latest release, which GitHub requires.

- **Public repositories** don't run on froog by default: anyone can open a pull request, and its jobs would run on your bill. `froog ci public on --users "me,a-teammate"` allows them for runs started by those GitHub users (no `--users`: anyone, then turn on "Require approval for all outside collaborators" in the repository's Actions settings). `froog ci public off` turns them off again.

- **Secrets.** A job's sandbox starts without your environment: no variables, secret files, GitHub token or keys. Use GitHub Actions secrets.

```sh
$ froog ci                                   # setup, 7-day stats, the last jobs
$ froog ci --repo acme/api --state failed    # --state all|active|failed, --limit
$ froog ci job ci_…                          # state, reason, wait, run time, sandbox
$ froog ci logs ci_…                         # the job's log from GitHub
$ froog ci metrics ci_…                      # CPU and memory over time, steps with their times
$ froog ci tests ci_…                        # test results (--all: passed ones too)
$ froog ci ssh ci_…                          # a shell on the job's VM while it runs
$ froog ci search 'level:error timeout'      # every finished job's log, line by line
$ froog ci stats --days 14                   # per repository and job: runs, failures, p50/p90, cost, flaky tests
$ froog ci rightsize                         # jobs that would fit another size better
$ froog ci caches                            # the /cache=NAME disks; --rm NAME starts one over
```

**In the dashboard.** [Pushbox](https://froogcorp.com/dashboard/#ci) lists your workflow runs, 25 to a page, newest first: each run's commit title, run number, who started it, its branch, when and how long, with filters for workflow (in the sidebar), repository, status and branch, and a search over titles, job names, commits and people. A run opens as a summary: what triggered it, its status, duration and cost, a box for each of its jobs on froog, annotations (the `##[error]`, `##[warning]` and `##[notice]` lines of each job's log, refusals, out-of-memory kills and failed tests) and usage per job. A job opens as its log, a collapsible section per step (the failed or running one open), with its CPU and memory and its tests. Insights, Log search, Monitors, Caches and Settings each have a page of their own under Pushbox. Runs, jobs and pages have their own links (`#ci/run/RUN_ID`, `#ci/job/ci_…`).

API: `GET /ci/runs?workflow=&repo=&branch=&state=&q=&limit=&offset=` (`{runs, total, workflows, repos, branches}`: a page of workflow runs, each with GitHub's `title`, `event`, `run_number`, `path` and `actor` and its `jobs`; 25 by default, up to 100), `GET /ci/runs/{run_id}`, `GET /ci?repo=&state=&limit=` (`{configured, connected, label, public_repos, public_users, stats, jobs}`), `GET /ci/jobs/{id}`, `GET /ci/jobs/{id}/logs` (text, the last 1 MiB, from GitHub, or the runner's output while GitHub has none), `PUT /ci/settings {public_repos, public_users}`, `GET /ci/jobs/{id}/metrics` (`{resources, points, steps}`), `GET /ci/jobs/{id}/tests` (`{summary, tests}`, failed first), `GET /ci/analytics?days=&repo=` (`{repos, jobs, flaky_tests}`), `GET /ci/logs/search?q=&days=&limit=&order=newest|oldest` (`{matches: [{job_id, repo, job_name, branch, line, step, at, text}]}`), `GET` / `POST /ci/monitors` (`{name, kind: failure|duration, repo, branch, workflow, job, log_pattern, duration_secs, consecutive, cooldown_mins, email}`, adding and removing need `account.admin`), `DELETE /ci/monitors/{id}`. Webhooks can send [`ci.job.*` events](https://froogcorp.com/docs/webhooks/). Job states: `queued` (waiting for a sandbox), `starting`, `running`, `completed` (with GitHub's `conclusion`), `refused` and `failed`, with `reason`. Reading needs `account.read`, the settings `account.admin`. Jobs are kept 30 days.

### actions/cache, and what's installed

**Your caches stay beside the VMs.** `actions/cache` and the actions that use it (`setup-node`, `setup-python`, `setup-go`, `setup-java`, `setup-dotnet`, `ruby/setup-ruby`, `Swatinem/rust-cache`, `astral-sh/setup-uv`, Gradle's) save to and restore from froog's own cache service instead of GitHub's, with no change to your workflow. Like GitHub's cache, a job writes its own branch's cache and reads its own branch's first, then the default branch's, so a pull request can't change what `main` restores. Pull requests from forks, and accounts under [zero data retention](https://froogcorp.com/docs/data/), use GitHub's cache as before, and so does a workflow that sets its own `NODE_OPTIONS`. Up to 10 GB per account is kept, the least recently used going first, and entries unused for 7 days are removed. If froog's cache can't answer, `actions/cache` carries on as on a miss. Other actions, such as `upload-artifact`, still talk to GitHub.

**What's installed.** The image has what GitHub's `ubuntu-latest` workflows usually expect: Docker with Buildx and Compose, Node 24 with pnpm, yarn and bun, Python with uv, Go, Rust, Java 21 with Maven, Ruby, PHP, .NET 8 and 10, the AWS CLI, kubectl, helm, gh, Chrome, shellcheck and the PostgreSQL and MySQL clients. `actions/setup-python` (3.10 to 3.13), `setup-node` (20, 22, 24) and `setup-go` (the last three releases) find those versions already in `/opt/hostedtoolcache` instead of downloading them, as on GitHub's runners. `cat /etc/froog-image` lists everything with versions. A `/cache=NAME` disk keeps what it was saved with: start one over (`froog ci caches --rm NAME`) to pick up a newer image.

### Insights: metrics, tests and the right size

froog keeps more about each job than GitHub does, for 30 days:

- **CPU and memory.** The VM is sampled every 30 seconds while the job runs: average and peak CPU, peak memory, and the kernel's out-of-memory kills. `froog ci metrics ci_…` draws them with the job's steps and how long each took.

- **Tests.** When a job ends, froog reads the JUnit XML files it wrote in its workspace (changed during the job, up to 900 KB): totals, and each failed test with its message. Most test runners write JUnit with a flag: `pytest --junitxml=report.xml`, `jest --reporters=jest-junit`, `vitest --reporter=junit --outputFile=report.xml`, `go test -v 2>&1 | go-junit-report > report.xml` or `gotestsum --junitfile report.xml`, `cargo nextest run --profile ci` (with `[profile.ci.junit]`), Maven Surefire and Gradle by default. Not under [zero data retention](https://froogcorp.com/docs/data/).

- **Flaky tests.** A test that failed and passed on the same commit (a re-run, or two jobs of a matrix) is listed as flaky, with how often.

- **Log search.** Each finished job's log is kept (the last 4 MiB), so you can search every job at once: `froog ci search 'branch:main level:error timeout'`, or the dashboard's Log search. Words must all be in the line (any case), `"a phrase"` must appear as is, `-word` excludes, and `repo:`, `branch:`, `workflow:`, `job:`, `conclusion:`, `step:` and `level:error` / `level:warning` narrow it down. `--oldest` lists the oldest job first, to find where a failure first appeared. Not under [zero data retention](https://froogcorp.com/docs/data/).

- **Monitors.** An alert when matching jobs fail or run long: by email to the account, and as the `ci.monitor.triggered` webhook event (`{"id", "type", "created_at", "monitor", "ci_job", "reason"}`) for Slack or your own tools. Match by repository, branch, workflow and job name (any part of the name). A failure monitor can wait for several failures in a row (a success starts over) and look for a pattern in the failed job's log (`OutOfMemory`, `SIGKILL`). A duration monitor fires once per job while it runs, when it passes the limit. After an alert, a monitor waits its cooldown (default 60 minutes) before the next. Up to 50 per account. `froog ci monitors add "main breaks" --repo acme/api --branch main`, `froog ci monitors add slow --duration 20m`, `froog ci monitors add oom --log-pattern OutOfMemory --consecutive 2`, `froog ci monitors`, `froog ci monitors rm cim_…`.

- **Analytics and size advice.** `froog ci stats` and the dashboard add up each repository and job: runs, failure rate, p50 and p90 run time and queue wait, minutes and cost, CPU and memory. After three runs at a size, a job gets advice when another size fits better: one size up when it ran out of memory or used 90% of it, or when its CPUs were busy 80% of the time (faster); one size down when it never used more than 45% of the CPU and 40% of the memory (half the price). `froog ci rightsize` lists them with the label to use. Each job's row also shows its slowest steps (p50 and p90 over its runs) and, with `/cache=`, how often it started from the cache instead of cold.

### Moving your workflows

`froog ci migrate`, in your repository, shows the `runs-on` lines it would change in `.github/workflows`; `--write` saves them. `ubuntu-latest`, `ubuntu-24.04` and `ubuntu-22.04` become `froog` (`--size small|large|xlarge` for another size, `--cache NAME` to add a warm disk). Blacksmith's `blacksmith-<N>vcpu-ubuntu-*` and Depot's `depot-ubuntu-*-<N>` become the froog size with the same vCPUs. Windows, macOS and Arm jobs stay where they are, and `runs-on` expressions (`${{ matrix.os }}`) are pointed out to change by hand.

```sh
$ froog ci migrate                   # show the changes
$ froog ci migrate --write --cache deps
$ git commit -am "CI on froog" && git push
```

To run your local changes on the same warm disk as CI, from your laptop or a coding agent: `froog run --from cache-NAME -- make test`.

### The CLI from any CI

`froog run` creates a sandbox, pushes the checkout to it, runs the command, prints the cost, deletes the sandbox and exits with the command's code, so the CI job fails when the tests fail. Create a key with the `ci` preset and store it as a secret:

```sh
$ froog api-key create github-ci --preset ci --ttl 90d
$ gh secret set FROOG_API_KEY        # paste the froog_… key it printed once
```

```sh
# .github/workflows/froog-tests.yml
name: Tests on froog
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    env:
      FROOG_API_KEY: ${{ secrets.FROOG_API_KEY }}
    steps:
      - uses: actions/checkout@v4
      - run: curl -fsSL https://froogcorp.com/install | sh
      - run: froog run --size large -- make test        # or --from ci-base, -e KEY=VALUE
```

Install your toolchain once and keep it as a saved snapshot (`froog snapshot box --name ci-base`), then `froog run --from ci-base` starts with it. The same two lines work in GitLab CI, CircleCI or Buildkite with `FROOG_API_KEY` set.

## Tokenmail: inboxes

Email addresses for agents, on `froogmail.com`: an agent signs up for a service with one, then reads the one-time code or follows the verification link itself. They only receive; nothing can be sent from them. **$2 per inbox a month**, on a subscription of its own (no sandbox plan needed). See [froog Tokenmail](https://froogcorp.com/tokenmail/).

```sh
$ froog inbox create --username signup --name "acme trial"   # prints signup-k3f9qz@froogmail.com (Stripe Checkout opens the first time)
$ froog inbox wait signup-k3f9qz@froogmail.com --code        # waits for mail with a code, prints just the code
$ froog inbox wait signup-k3f9qz@froogmail.com --link --from acme   # or the verification link
$ froog inbox messages signup-k3f9qz@froogmail.com           # what arrived, newest first
$ froog inbox read signup-k3f9qz@froogmail.com msg_…         # one message; --html, --raw
$ froog inbox list · billing · rm ADDRESS

# for the agent in a VM: attach one, and it's $FROOG_EMAIL there from the VM's next start
$ froog inbox create --sandbox my-vm          # or: froog inbox attach ADDRESS my-vm · detach ADDRESS
$ CODE=$(froog inbox wait "$FROOG_EMAIL" --code)   # inside my-vm
```

Each message comes with the one-time `codes` and verification `links` found in it, most likely first, so an agent doesn't have to parse mail. A code is 4 to 8 digits (`123456`, `123-456`) or 6 to 8 capitals and digits, next to a word like "code" or "verify"; prices, years and phone numbers aren't codes. Footer links (unsubscribe, privacy, social) are left out. `wait` also counts mail from the last minute, so start it right after submitting the form; `--subject` filters too, `--timeout` is in seconds (default 300), and `--json` prints the whole message. Name an inbox by its full address, the part before `@`, or its id. Mail to `name+anything@froogmail.com` goes to `name@froogmail.com`.

**Paying.** Inboxes are seats on a Stripe subscription separate from your sandbox plan: no free tier, no trial. The first `froog inbox create` opens Checkout and waits until you've paid. After that, each new inbox uses a free seat or adds one, with the rest of the month charged at once. Delete an inbox and its seat stays paid until the period ends (a new inbox can use it); at the renewal you're billed for the inboxes you have, with no credit for removals. With none left, the subscription ends at the period's end. If a payment fails and the subscription lapses, mail to your inboxes is refused until it's paid. `froog inbox billing` shows seats, inboxes and the renewal; cancel or change the card in Manage billing (the Stripe customer portal).

**In the dashboard.** [Tokenmail](https://froogcorp.com/dashboard/#email) is a mailbox: your inboxes on the left (with unread counts), the open inbox's messages in the middle (sender, subject, a preview and the code), and the open message on the right, its code and verification link at the top and the mail below. HTML mail shows in a locked-down frame, with no scripts and no remote images, and its links open in a new tab. The page checks for new mail every 15 seconds. Copy an address, attach an inbox to a sandbox or delete it from the top of its message list.

**In a VM.** VMs don't get an inbox by themselves. Attach one (one per VM) and it's `$FROOG_EMAIL` there from the VM's next start. The key inside the VM reads only that inbox: a key limited to some sandboxes sees only their inboxes, and can't buy, create, change or delete inboxes. Deleting the VM detaches the inbox; it stays yours.

Only mail for an existing inbox is accepted; anything else is refused while the sender is still connected, so nobody gets a bounce from us later. Mail arrives over TLS when the sender supports it. Messages are kept 30 days. A deleted inbox's address is never given out again, so nobody receives a previous owner's codes.

Use them for services that allow it. Don't use them to create accounts in bulk, to get around a ban, or to get past a CAPTCHA: when a sign-up shows one, solve it yourself in the VM's [desktop](https://froogcorp.com/docs/desktop/) and let the agent carry on (see the [acceptable use policy](https://froogcorp.com/acceptable-use/)).

API: `GET`/`POST /v1/inboxes` (`{username?, name?, sandbox?}`; `402 inbox_payment_required` without a subscription), `GET`/`PATCH`/`DELETE /v1/inboxes/{inbox}` (`PATCH {name?, sandbox?}`, `""` detaches), `GET /v1/inboxes/billing` (`{required, active, price_cents, seats, used, status?, period_end?}`), `POST /v1/inboxes/billing/checkout {quantity?}` (a Checkout `url`), `GET /v1/inboxes/{inbox}/messages?since=&limit=` (without bodies), `GET`/`DELETE /v1/inboxes/{inbox}/messages/{id}` (with `text` and `html`), `GET …/messages/{id}/raw` (`message/rfc822`), and `GET /v1/inboxes/{inbox}/wait?since=&after=&sender=&subject=&timeout=`, which holds the request until a matching message arrives (up to 120 seconds) and answers `{message}`, `null` on timeout. Reading needs `inbox.read`, the rest `inbox.write` (making an inbox can add a paid seat). The SDKs have them as `createInbox`, `waitForInboxMessage`, `getInboxBilling`, `buyInboxes` and so on.

## Webhooks

Get an HTTPS POST when a sandbox becomes `running`, stops, fails or is deleted, instead of polling.

```sh
$ froog webhooks add https://example.com/froog --name prod       # all events; prints the signing secret once
$ froog webhooks add https://example.com/froog --event sandbox.error
$ froog webhooks test wh_…     # send a ping now
$ froog webhooks update wh_… --event sandbox.stopped --disable   # or --url, --name, --enable
$ froog webhooks deliveries wh_…   # recent attempts and their results
$ froog webhooks list · rotate wh_… · rm wh_…
```

Events: `sandbox.running`, `sandbox.stopped`, `sandbox.error`, `sandbox.deleted`, `sandbox.expired` (a stopped sandbox's disk was deleted after 14 days), `sandbox.degraded` (its host stopped answering, its VM stopped and is being started again so processes may have ended, or it isn't [answering](https://froogcorp.com/docs/sandboxes/#unresponsive)), `sandbox.recovered` (also when an [errored sandbox](https://froogcorp.com/docs/sandboxes/#rebuild) was rebuilt), `sandbox.stop_refused` (a stop's snapshot failed, see [Stopping safely](https://froogcorp.com/docs/sandboxes/#stopping)), `sandbox.disk_low` (its disk is 85% or 95% full, see [Disk space](https://froogcorp.com/docs/sandboxes/#disk)), and `sandbox.port_opened` / `sandbox.port_closed` (a TCP port started or stopped listening inside it. The body adds `port` and `protocol`). Port events go only to endpoints that list them in `events`, not to those that take every event: froog checks the running sandboxes of such accounts every 30 seconds, so a port that opens and closes in between isn't seen. For every change as it happens, use [the port stream](https://froogcorp.com/docs/api/#watch). CI job events, also only to endpoints that list them: `ci.job.started`, `ci.job.completed` (any conclusion, with what the job used and its test totals), `ci.job.failed` (failed or timed out on GitHub, or froog couldn't run it), `ci.job.refused`, and `ci.monitor.triggered` (a [CI monitor](https://froogcorp.com/docs/ci/#ci-insights) fired; the body adds `monitor` and `reason`); their body is `{"id", "type", "created_at", "ci_job": {…}}` with the job as `GET /ci/jobs/{id}` returns it. The body is `{"id", "type", "created_at", "sandbox": {…}, "reason"?}`, `reason` saying why for degraded, recovered, stop_refused and disk_low, with the sandbox as `GET /sandboxes/{name}` returns it. Answer with any 2xx status within 10 seconds. Anything else is tried again, up to 8 attempts in all: after 5 seconds, 30 seconds, 2, 10 and 30 minutes, 1 hour and 2 hours. An event can arrive more than once and in no guaranteed order, so deduplicate on `id`. Delivery is at least once: pending retries are stored, so a restart of the control plane resumes them on schedule.

Requests also carry `froog-event` (the type), `froog-delivery` (the event `id`, the same on every attempt), `froog-attempt` (1 to 8) and `froog-webhook` (the endpoint's id). Every attempt, test pings included, goes in the endpoint's delivery log for 30 days, with its result, HTTP status and time: `froog webhooks deliveries`, `GET /v1/webhooks/{id}/deliveries`, or Deliveries in the dashboard. A paused endpoint (`enabled: false`) gets no events until you turn it back on.

Every request carries `froog-signature: t=<unix time>,v1=<hex>`, where `v1` is HMAC-SHA256 of `"<t>.<raw body>"` with your signing secret. Check it, and reject old timestamps:

```sh
// Node.js
const crypto = require('crypto');
function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const want = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return Math.abs(Date.now() / 1000 - Number(t)) < 300 &&
    crypto.timingSafeEqual(Buffer.from(want), Buffer.from(v1));
}
```

Endpoints must be HTTPS on port 443 or 8443 at a public address, and redirects aren't followed. Up to 10 per account.

## OpenTelemetry

Send sandbox and account metrics, and lifecycle events, to Datadog, Grafana Cloud, Honeycomb or any OTLP collector. froog pushes to one OTLP/HTTP endpoint per account, and nothing is installed in your sandboxes. No traces, no command output.

```sh
$ froog telemetry set https://otlp.datadoghq.com -H dd-api-key=$DD_API_KEY
saved. test export accepted; metrics arrive within a minute.
$ froog telemetry          # endpoint, header names, last export, last error
$ froog telemetry unset
```

Give the collector's base URL, and froog adds `/v1/metrics` and `/v1/logs`. It must be HTTPS on port 443 or 4318 at a public address. Header values are encrypted and never shown again. Saving sends an empty metrics export and reports the collector's answer. Vendors: Datadog `https://otlp.datadoghq.com` with `dd-api-key`, Grafana Cloud's OTLP endpoint with `Authorization=Basic <base64 instanceId:token>`, and Honeycomb `https://api.honeycomb.io` with `x-honeycomb-team`.

| Metric (gauge, every 60 s) | Unit | Meaning |
| --- | --- | --- |
| `froog.sandbox.cpu.utilization` | 1 | Share of CPU time busy since the last sample |
| `froog.sandbox.memory.utilization` · `.usage` | 1 · By | Memory in use |
| `froog.sandbox.filesystem.utilization` · `.usage` | 1 · By | Disk in use |
| `froog.sandbox.io.pressure` | 1 | Share of time stalled on disk I/O (PSI, 60 s average) |
| `froog.account.sandboxes.active` · `.limit` | {sandbox} | Running now · allowed at once |

Each sandbox is a resource with `service.name=froog`, `froog.sandbox.id`, `froog.sandbox.name`, `froog.sandbox.size`, memory and disk sizes and `froog.account.id`. Sandboxes with [monitoring off](https://froogcorp.com/docs/data/) send no metrics. To read one sandbox's numbers now, without a collector, use [`froog metrics`](https://froogcorp.com/docs/sandboxes/#metrics). Every [webhook event](https://froogcorp.com/docs/webhooks/) is also an OTLP log record (`eventName` and `event.name`, severity ERROR for `sandbox.error`, WARN for `sandbox.degraded`, else INFO, `froog.sandbox.reason` when there is one, with `froog.event.id` matching the webhook's id). A collector answering 429 or 502-504 gets the same metrics again next minute (up to 10 minutes' worth). Other errors drop that window and show in `last error`. Events are retried up to 8 times.

## Desktop

Every VM has a Linux desktop (XFCE, 1920x1080, with Chrome) that you can watch and use in your browser, for example to see what an agent is doing. Click **Desktop** next to a running VM in the [dashboard](https://froogcorp.com/dashboard/), or:

```sh
$ froog desktop my-box                 # opens it in your browser
$ froog desktop my-box --theme light   # dark (default) or light
$ froog desktop my-box --print         # just print the link
```

The link opens froog's viewer. Its **☰** button has fullscreen, reconnect, the connection (below), scaling and picture quality (VNC only), Ctrl+Alt+Del and other keys, a clipboard box (text copied in the sandbox shows up there, and text you put there can be sent in) and a view-only switch. A line at the bottom left shows the resolution, frame rate (or data rate), connection and session time. The desktop starts the first time you open it and keeps running until the VM stops.

**Fast stream, with VNC as the fallback.** The viewer shows the desktop as H.264 video at up to 60 frames a second, encoded on the sandbox's CPU by [Selkies](https://github.com/selkies-project/selkies) and sent over the same HTTPS address as the page. If the stream isn't there or shows nothing within a few seconds, the viewer uses VNC instead, as before. The stream runs on Ubuntu and Debian sandboxes. The first open installs it (about 20 seconds more, once). Omarchy uses VNC. Several pages can show one desktop at once (it is encoded once for all of them). View-only pages, like the wall's tiles or an embed with `viewonly=1`, join as watchers that can't send input. Pick **Compatible (VNC)** under **☰ → Connection** to stay on VNC (the viewer remembers it), or add `transport=vnc` to the link's query for one page.

What it costs, measured on a `default` sandbox (4 vCPUs on an Intel i7-6700 host) with one viewer at 1920x1080, the stream uses about 0.2 vCPU on a still screen (plus about 0.15 in the X server handing it frames) and sends nothing (only changed frames go out), about 0.4 vCPU while a terminal scrolls as fast as it can, and about 1 vCPU for full-screen motion at 60 frames a second and 6 Mbit/s. A `small` sandbox (2 vCPUs) also kept 60 frames a second of full-screen motion, at about 0.8 vCPU. VNC sent about 160 Mbit/s for the same motion. Nothing is encoded while no one watches.

**Sound.** What the desktop plays comes through the fast stream, as Opus at 128 kbit/s. The desktop has a sound server of its own (PulseAudio, with one virtual speaker that every program plays to), so no sound card is needed. Browsers start playing it after your first click in the page. Turn it off with **Sound** under **☰** (the viewer remembers it), or add `sound=0` to the link's query for one page. Off also stops the sandbox capturing and encoding it, and can turn it off for other pages open on the same desktop. View-only pages have no switch. VNC, the browser view and Omarchy have no sound. The cost: about 0.1 vCPU while a viewer is connected with sound on and something plays, for the sound server and the Opus encoder (measured on a `small` sandbox, Intel i7-6700 host: 0.18 vCPU for the stream with sound off, 0.26 to 0.29 with it on). With no viewer the sound server alone uses about 0.03 vCPU while something plays. Desktops started before this release get sound when the sandbox restarts.

**Links.** A link carries a secret token after the `#` (browsers never send that part to a server, and the viewer takes it out of the address bar at once). Open it within 10 minutes. A session you have open keeps working after that. Reconnecting within the 10 minutes works. After that, or after a stop, ask for a new link. Treat links as secrets and don't log them. A link opens only its own view: a desktop link can't open the browser view, nor the other way round.

**Embed it.** From your server, call `POST /v1/sandboxes/{name}/desktop` (it returns `{url, expires_at}`) on each page load, add `overlay=0` to the query (before the `#`) to hide the controls and stats, and put it in an iframe:

```sh
<iframe src="DESKTOP_URL" allow="autoplay; fullscreen; clipboard-read; clipboard-write" style="width:100%;aspect-ratio:16/9;border:0"></iframe>
```

**Your own background.** Put a PNG at `~/.config/froog/wallpaper.png` on the sandbox, and the desktop shows it within two seconds, filling the screen and keeping its aspect ratio. Up to 16 MiB, 8192 pixels a side and 16,777,216 pixels in all. A file that isn't a valid PNG is ignored. Delete it to go back to the plain background. It's on the disk, so it stays through stops, snapshots and forks: `froog scp ./wallpaper.png my-box:.config/froog/wallpaper.png`.

**Browser only.** To show someone Chrome and nothing else, open the browser view: only Chrome, on its own 1920x1080 screen with no desktop behind it, with its tabs and address bar but no window controls. Closing the last tab opens a new window, so the view never ends on a blank screen. It runs next to the desktop (a separate screen and a separate Chrome) and has no clipboard.

```sh
$ froog browser my-box                               # profile ~/.config/froog-browser
$ froog browser my-box --profile /data/chrome-work   # another signed-in profile
```

A profile is Chrome's user data directory: any absolute path outside the system directories, created if missing, kept on the disk (so it goes with snapshots and forks). Asking for another profile closes the open one. API: `POST /v1/sandboxes/{name}/browser` with `{"profile": "/data/chrome-work"}` (optional). The browser view isn't a security boundary against code running in the sandbox, which can read Chrome's profile.

**Record it.** `froog-record-desktop` records the desktop to an MP4 (H.264, 30 fps, with the desktop's sound as AAC) from inside the sandbox:

```sh
$ froog exec my-box -- froog-record-desktop start /home/user/demo.mp4
# … drive the desktop, or let an agent do it …
$ froog exec my-box -- froog-record-desktop stop      # finishes the file
$ froog scp my-box:demo.mp4 .
```

`froog-record-desktop status` says whether it's recording. Stop before you download: the file isn't playable until then. `froog desktop my-box --tunnel` reaches the desktop through an SSH tunnel to `localhost` instead of a link.

## Windows apps (Wine)

A sandbox can run many Windows programs on its desktop with [Wine](https://www.winehq.org). They open as ordinary windows: you watch them with `froog desktop`, and agents drive them with the [computer tools](https://froogcorp.com/docs/agents/) (screenshots, clicks and keys, Wine programs have no accessibility tree). Install once, then save the sandbox as a snapshot and start new sandboxes from it.

**Install Wine.** Save this as `install-wine.sh` and run it in the background (it takes several minutes, longer than a plain `froog exec` waits):

```sh
$ froog exec my-box --detach -- bash -s < install-wine.sh
$ froog logs my-box JOB -f
```

```sh
set -e
. /etc/os-release
sudo dpkg --add-architecture i386
sudo mkdir -pm755 /etc/apt/keyrings
sudo wget -qO /etc/apt/keyrings/winehq-archive.key https://dl.winehq.org/wine-builds/winehq.key
# WineHQ's packages for this release (Ubuntu or Debian); the distribution's own Wine if it has none yet
if sudo wget -qNP /etc/apt/sources.list.d/ "https://dl.winehq.org/wine-builds/$ID/dists/$VERSION_CODENAME/winehq-$VERSION_CODENAME.sources"; then
  sudo apt-get update -qq
  sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --install-recommends winehq-stable
else
  sudo apt-get update -qq
  sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wine
fi
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends cabextract p7zip-full mesa-vulkan-drivers
sudo wget -qO /usr/local/bin/winetricks https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks
sudo chmod +x /usr/local/bin/winetricks
bash ~/.froog/desktop/desktop.sh >/dev/null      # the desktop, display :1
export DISPLAY=:1
WINEDLLOVERRIDES="mscoree,mshtml=" wineboot -i  # no "Wine Mono Installer" window waiting for a click
winetricks -q corefonts vcrun2022
wine --version
```

WineHQ's builds are newer than Ubuntu's and Debian's own Wine, which many current programs need. `winetricks` adds Microsoft's free redistributables: the core fonts and the Visual C++ 2015-2022 runtime. Programs that need .NET get it the same way (`winetricks -q dotnet48`), and Direct3D programs `winetricks -q dxvk`, which runs on the CPU through `mesa-vulkan-drivers` (sandboxes have no GPU, so only light 3D is usable). On Omarchy, install `wine` and `winetricks` with `pacman` from the multilib repository instead.

**Install and start a program.** Download the vendor's installer in the sandbox, install it silently, and start it with `wine start /unix`. Every command that opens a window needs `DISPLAY=:1`:

```sh
# in the sandbox (froog ssh my-box): 7-Zip, from 7-zip.org
export DISPLAY=:1
curl -fsSLo /tmp/7z.exe https://www.7-zip.org/a/7z2501-x64.exe
wine /tmp/7z.exe /S
wine start /unix "$HOME/.wine/drive_c/Program Files/7-Zip/7zFM.exe"
```

- Silent switches: `/S` for NSIS installers, `/VERYSILENT` for Inno Setup, `msiexec /i app.msi /qn` for MSI. The `InstallerUrl` in a program's [winget manifest](https://github.com/microsoft/winget-pkgs/tree/master/manifests) is the vendor's own download link.

- `wine start /unix PATH` gives the program normal Windows console handles. Some programs crash without them.

- `C:\` is `~/.wine/drive_c`, and `Z:\` is the Linux root `/`.

- Licence windows on first start (e.g. Sysinternals tools) can be accepted beforehand in the registry, so an agent doesn't have to: `wine reg add 'HKCU\Software\Sysinternals\Process Explorer' /v EulaAccepted /t REG_DWORD /d 1 /f`.

- Old games that change the screen resolution: `wine reg add 'HKCU\Software\Wine\X11 Driver' /v UseXRandR /t REG_SZ /d N /f`.

**Set up once, start many.** Wine and its programs are in `~/.wine` and the system packages, so a snapshot keeps both: `froog snapshot my-box --name wine-apps`, then `froog new task-1 --from wine-apps` for each task. Running programs don't survive a stop or a fork: start them again.

**Which programs run.** We haven't verified any Windows program on froog yet. Programs that Wine runs well elsewhere and that make good first tries: Notepad++, 7-Zip, WinMerge, HxD, IrfanView, Sysinternals Process Explorer, LTspice. Programs that load Windows kernel drivers (hardware tools, anti-cheat, some VPN and backup software) don't run under Wine at all. [Wine's AppDB](https://appdb.winehq.org) has reports for most programs.

| Problem | Fix |
| --- | --- |
| `wineboot` never finishes | The "Wine Mono Installer" window waits for a click: run `WINEDLLOVERRIDES="mscoree,mshtml=" wineboot -i`. |
| `Cannot open display` | `export DISPLAY=:1`, and start the desktop (`froog desktop`, or `bash ~/.froog/desktop/desktop.sh` inside). |
| `unimplemented function msvcp140.dll…` | `winetricks -q vcrun2022` |
| A program exits at once with `init_sys_streams` | Start it with `wine start /unix`. |
| Windows go blank and show "Not Responding" | A dialog was closed from Linux (`close_window`, `wmctrl -c`) while it was modal. Answer dialogs with keys (`press_key Return`) instead. |

Install only software you have the right to use, from its vendor. Don't copy Windows system files into Wine: Microsoft's licence doesn't allow it, and the programs above don't need them.

## API keys and scopes

Scripts, CI and agents use API keys. Give each key only what it needs: a key has a set of **actions**, and can be limited to some **sandboxes**, by name or by [label](https://froogcorp.com/docs/sandboxes/#labels). A request is allowed only when its action is on the key and, for a request about one sandbox, that sandbox is on the key (or the key created it) and has the key's labels. Anything else gets `403 api_key_action_forbidden` or `403 api_key_sandbox_forbidden`.

```sh
$ froog api-key create ci-bot --preset ci --ttl 30d          # prints the key once
$ froog api-key create watcher --preset read-only
$ froog api-key create box-agent --sandbox my-box            # everything on my-box, nothing else
$ froog api-key create team-a --preset ci --label team=a     # only sandboxes labelled team=a
$ froog api-key create ci-bot --preset ci --env ci           # only sandboxes of the ci environment
$ froog api-key create custom --actions sandbox.read,exec,file.read --ttl 7d
$ froog api-key list                                          # scope, expiry, last use, requests in 30 days, sandboxes it created
$ froog api-key rotate key_3f2a9c1d8e7b                       # new secret, same id, scope and expiry
$ froog api-key revoke key_3f2a9c1d8e7b
$ froog api-key actions                                       # every action and preset
```

| Preset | Can |
| --- | --- |
| `read-only` | Inspect sandboxes, their history and processes, files, snapshots and the environment. |
| `full-sandbox` | Operate sandboxes: exec, prompt, files, SSH, desktop, public URLs, snapshots, stop, rename. Not create, start, fork or delete, and no account changes. |
| `ci` | `full-sandbox`, plus create, start and fork, and delete the sandboxes this key created. |
| `admin` | Everything, including billing, webhooks and creating more keys. |

Actions: `sandbox.create`, `sandbox.read`, `sandbox.update`, `sandbox.stop`, `sandbox.resume`, `sandbox.fork`, `sandbox.delete`, `sandbox.delete-own`, `agent.prompt`, `exec`, `file.read`, `file.write`, `ssh`, `desktop`, `host`, `snapshot.read`, `snapshot.write`, `environment.read`, `environment.write`, `account.read`, `account.admin`, `inbox.read`, `inbox.write` ([email inboxes](https://froogcorp.com/docs/inboxes/); the key inside a sandbox has both, for the inbox attached to it only), `billing.trial` (read billing and start the free trial, nothing else, `account.admin` includes it), and `*` for everything.

**The key inside each sandbox.** Every sandbox has the `froog` CLI, signed in with a key that reaches only that sandbox, so the agents in it can run `froog host "$(hostname)" 3000`, take snapshots or stop it. It can read, rename and stop the sandbox, exec, prompt, read and write files, SSH, open the desktop, host ports and read and save snapshots. It can't create, start, fork or delete sandboxes, reach other sandboxes, or change the account. It is replaced at every start and revoked when the sandbox stops or is deleted, and it isn't listed in `froog api-key list`. A sandbox created with `--no-environment` gets no key.

- Every key expires: 90 days by default, at most 365, or `--ttl never`. An expired key gets `401 api_key_expired`.

- A key that can create keys (`account.admin`) can't create one with more actions, more sandboxes, fewer labels or a later expiry than its own. Approving an `froog login` needs the dashboard or a full key.

- A key limited to sandboxes lists only those (plus the ones it created), and sees no API keys or audit log.

- A key limited by environment (`froog api-key create ci-bot --preset ci --env ci`, repeat `--env` for several, API `environments`) reaches only sandboxes of those [environments](https://froogcorp.com/docs/environment/#environments), in lists too, and sees only those environments. What it creates goes into its first environment unless it names another of its own. Naming any other gets `403 api_key_environment_forbidden`, for forks and starts too. Like any limited key it can't change environments (`environment.write`), and a key it creates must be limited to its environments or fewer.

- A key limited by labels (`--label KEY=VALUE`, repeat for several, API `labels`) reaches only sandboxes that have all of them, in lists too. Sandboxes it creates or forks get its labels, and asking for another value of one gets `403 api_key_label_forbidden`, as does changing or removing those labels on a sandbox. A key it creates must have at least its labels. The [MCP endpoint](https://froogcorp.com/docs/mcp/) checks the same way.

- Keys made before scopes, and dashboard keys with **Everything**, have full access. The CLI asks for a scope, and full access needs `--preset admin`.

- Revoking a key stops it at once, but sandboxes it created stay.

- Rotating gives a key a new secret and stops the old one at once. Its id, scope and expiry stay. An expired key can't be rotated (`409 api_key_expired`): create a new one. For no downtime, create a new key, deploy it, then revoke the old one.

## Sign-in and connections

Sign in to the dashboard (and so to `froog login`) with Google, GitHub, a 6-digit code sent by email, or [AgentID](https://agentid.com) for an AI agent with its own AgentMail inbox. No method is more correct than another.

Each method you use is a **connection** on your account. All of them open the same account, with the same sandboxes, settings and billing. Add one on the dashboard's Account page while signed in. The first sign-in with a new method matches an account by its verified email address (an invited account), else makes a new account when signups are open, else joins the waitlist. To add a second method to the account you have, sign in with your usual one and add it from the Account page.

- You can remove any connection except the last one (`409 last_connection`).

- The **primary** connection's address is your account's email: notices about money, the trial and your sandboxes go there. Make another connection primary to change it. Removing the primary one makes the next one primary.

- Email codes have 6 digits, work once, for 10 minutes. Five wrong tries end a code, and asking for a new one ends the previous one. At most 5 codes an hour per address from one network (an IPv4 address or an IPv6 /64), 20 per network, and 30 per address in all (`429 rate_limited`). Signing in and adding an address are counted apart, so someone asking for codes for your address can't lock you out.

- AgentID's address counts only when AgentID says it is verified (`email_verified: true`).

- A sign-in with Google, email or AgentID doesn't give froog access to your code. That's the [GitHub connection](https://froogcorp.com/docs/github/).

```sh
$ froog connections                          # id, method, address, which one is primary
$ froog connections add-email you@work.com   # asks for the code it emails
$ froog connections primary con_…
$ froog connections rm con_…
```

API: `GET /account/connections` (`[{id, provider, email?, login?, primary, created_at}]`), `DELETE /account/connections/{id}`, `POST /account/connections/email {email}` then `POST /account/connections/email/verify {email, code}`, `PUT /account/primary {connection}`. Listing needs `account.read`. Changes need the dashboard or a full-access key, and go in the [audit log](https://froogcorp.com/docs/audit/).

## Organizations

An **organization** is a team's shared workspace: one plan, one wallet, and one set of sandboxes, environments, saved snapshots, API keys, webhooks, GitHub connections and CI, with members who work in it. Your own account stays your **personal** workspace, exactly as before. Nothing changes until you create or join an organization.

```sh
$ froog org create Acme                     # you become its owner
$ froog org switch acme                     # the CLI, your account keys and MCP act in Acme from now on
$ froog org invite dana@example.com         # emails a link (7 days, one use)
$ froog org members                         # members, caps, usage, pending invites
$ froog new web                             # bills Acme; every member sees it
$ froog --org personal new scratch          # one command in your own account
$ froog org switch personal                 # back to your own account
```

**Which workspace a request acts in.** In order: an organization API key's organization. Otherwise the `X-Froog-Org` header (an organization's id `org_…`, its name in any case, or `personal`, `--org` or `FROOG_ORG` in the CLI, `org` in the SDKs). Otherwise, for an account API key, your account's active organization (`froog org switch`, `PATCH /orgs/active`). Else personal. The dashboard doesn't use the active organization: it sends the one picked in its header. A name that matches none of yours gets `404 org_not_found`. Two of yours with the same name get `409 ambiguous_org` (use the id). `GET /account` answers with your own id and email, plus `org` when the request acted in one. A sandbox stays in the workspace it was created in, and a fork in its source's.

| Belongs to the organization | Belongs to each person |
| --- | --- |
| The sandboxes it pays for, the plan, the wallet, usage and invoices, environments and variables, saved snapshots and share codes, organization API keys, webhooks, OpenTelemetry, GitHub installations and CI, AI subscriptions, alerts and data controls, the audit log | Sign-in connections, SSH keys, account API keys, the active organization, closing the account. Per member: their role, their caps, and who created each sandbox and key |

### Roles

| Role | Can |
| --- | --- |
| `member` | Create sandboxes and use shared ones. Read the environment, billing and usage. Save snapshots. Make organization keys (and see only their own). |
| `admin` | Also invite and remove members, set caps, change the environment, AI proxy keys and settings, webhooks, OpenTelemetry, GitHub and CI settings and alerts, delete saved snapshots and share codes, stop and delete any sandbox, read the audit log, and see every organization key. |
| `owner` | Also the plan, the wallet and seats, data controls, inviting admins, roles, renaming, transferring and deleting the organization. One per organization. |

A member without the role gets `403 org_role_forbidden`. The owner can't leave: `froog org transfer EMAIL` makes another member the owner, and you become an admin. You can't close your account while you own an organization (`409`).

### Members and invites

Owners and admins invite by email (`froog org invite EMAIL`, `--admin` invites an admin, owner only). The email holds a link to the dashboard: whoever opens it signs in with any method and joins. `froog org accept TOKEN` takes the token (`finv_…`) or the whole link. A link works once, for 7 days (`404 invite_not_found`, `410 invite_expired`), and a new invite to the same address replaces the old one. The answer to the invite holds the link too, for when email isn't set up. An organization needs a plan before it can add members (`402 billing_required`). Removing a member (`froog org remove EMAIL`) or a member leaving (`froog org leave`) stops their organization keys at once. The sandboxes they created stay with the organization.

### Shared and private sandboxes

Every member sees every sandbox of the organization. Each one is `shared` (every member can use it: exec, files, SSH, prompts, ports, start and stop) or `private` (only its creator uses it, members see it and its usage). New sandboxes start shared, unless the organization's default is private (`froog org default-visibility private`, owners and admins) or you pass `froog new --visibility private` (API `visibility`). A fork of a private sandbox is private. Its creator (or the owner) runs `froog unshare NAME` or `froog share NAME`. Lists and `GET /sandboxes/{name}` add `created_by` (the member's email), `visibility` and `access`: `owner` (you created it), `use` or `view`. Using a teammate's private sandbox gets `403 sandbox_private`, also through its saved snapshots (starting from one, browsing its files, a share code. The owner may), and deleting someone else's shared sandbox takes an admin. SSH keys are personal: a shared sandbox lets in every member's keys, a private one only its creator's (while they are a member). froog rewrites `authorized_keys` on running sandboxes when this changes: `share` and `unshare`, a member joining, leaving or being removed, and a member adding or removing a key, so access ends at once rather than at the next start. What a member did inside a shared sandbox while they could (with `sudo`) stays: delete or rebuild sandboxes you don't trust. A member who isn't an owner or admin can't save a snapshot under a name a teammate's sandbox holds (that would replace their copy): `403 saved_snapshot_name_taken`.

### Organization API keys

A key made in an organization (`froog --org acme api-key create ci --preset ci`, or the dashboard with Acme picked) is an **organization key**. It always acts in that organization, as the member who made it, and never with more than their role. Naming another workspace gets `403 api_key_org_mismatch`. It can't change webhooks, AI subscriptions or AI proxy keys and settings, billing or API keys, or use the routes about a person (organizations, SSH keys, sign-in, closing): `403 api_key_org_forbidden`. When its member leaves, it stops (`401`). Members list and revoke their own organization keys. Admins see all of them, with `created_by`. Your account keys, made before organizations or in your personal workspace, keep meaning your account: they act in an organization only when a request names it or you made it the active one. A key limited to some sandboxes, and the key inside each sandbox, always stays in its own workspace.

### Seats, limits and per-member caps

An organization pays the plan price **per seat**: 5 seats on the $20 plan cost $100 a month, and each seat adds the plan's included usage and saved snapshots. The seats billed are the larger of the members and the seats the owner bought (`froog org seats 5`, `PUT /orgs/{org}/seats`). More seats are charged at once, prorated. Fewer cost less from the next invoice, with no credit for the current period, and never go below the members. A member who joins past the seats bought adds a paid seat as they accept. A declined card fails the accept (`402`, "could not add the paid seat"). Organizations get no free trial. Each seat past the first also adds 20% of the plan's sandboxes at once and start limits, up to the next plan's sandboxes at once. On the largest plan each seat adds the full limits. These limits are one pool for the whole organization. Without a live plan, an organization has the limits of one seat.

One wallet means one member can spend it all, so owners and admins can cap each member (`froog org cap EMAIL --running 3 --usage 50`, 0 removes a cap, only the owner caps the owner):

| Cap | Limits | Past it |
| --- | --- | --- |
| `max_running_sandboxes` | The organization sandboxes the member runs at once | `429 member_limit_reached` on create, fork and start |
| `max_usage_usd` | The member's organization machine time this billing period, at list prices | `402 team_member_cap_reached` |

A sandbox counts against its creator, also when a teammate starts it. Within a minute of going over (a cap lowered, or usage that passed it), froog stops the member's newest organization sandboxes. Nothing is lost. Personal sandboxes don't count.

**Audit log.** Each organization has its own [audit log](https://froogcorp.com/docs/audit/) (owners and admins). Every entry names the member: `actor_label` is their email, with the key's label for a key. Organization changes are there too: `org.create`, `org.invite`, `org.invite_revoke`, `org.join`, `org.leave`, `org.member_remove`, `org.member_role`, `org.member_caps`, `org.member_cap_stop`, `org.transfer`, `org.seats`, `org.rename`, `org.default_visibility`, `org.delete`, `sandbox.share` and `sandbox.unshare`. Organization emails (alerts, billing) go to the owner.

**Delete an organization** with `froog org delete`: the owner, as its last member, after deleting its sandboxes. Its plan ends at once without a refund, its keys stop, and its data is erased 30 days later like a [closed account](https://froogcorp.com/docs/data/#close-account)'s.

API: `GET /orgs` (`{orgs: [{id, name, kind: personal|org, role, active, members}]}`, personal first), `POST /orgs {name}`, `PATCH /orgs/active {org}`, `GET · PATCH · DELETE /orgs/{org}` (`{name?, default_visibility?}`), `GET /orgs/{org}/members`, `PATCH · DELETE /orgs/{org}/members/{member}` (`{role?, max_running_sandboxes?, max_usage_usd?}`, a member by id or email), `POST /orgs/{org}/invites {email, role?}`, `DELETE /orgs/{org}/invites/{id}`, `POST /orgs/{org}/leave`, `POST /orgs/{org}/transfer {member}`, `PUT /orgs/{org}/seats {seats}`, `GET /org-invites/{token}`, `POST /org-invites/accept {token}`, and `POST /sandboxes/{name}/share` and `/unshare`. Reading organizations needs `account.read`, changing them `account.admin`, and accepting an invite a dashboard session or a full-access key. These routes always act for you, whatever the workspace.

## Sign up from an agent

An AI agent can sign up for froog without a human pasting an API key. froog implements [auth.md](https://workos.com/auth-md), the WorkOS agent registration protocol, like boat. The contract is a machine-readable file at [froogcorp.com/auth.md](https://froogcorp.com/auth.md). Give the agent this instruction:

```sh
Follow https://froogcorp.com/auth.md and start a froog sandbox free trial for me.
```

- It finds the endpoints: a `401` from the API carries `WWW-Authenticate: Bearer resource_metadata="https://api.froogcorp.com/.well-known/oauth-protected-resource/v1"`, and `/.well-known/oauth-authorization-server` has the `agent_auth` block (the same document as for [MCP clients](https://froogcorp.com/docs/mcp/) when OAuth for MCP is on: its token endpoint takes both flows' grants).

- It registers: `POST /v1/agent/identity` with `{"type": "anonymous"}`, or `{"type": "service_auth", "login_hint": "you@example.com"}` when it knows your email.

- It sends you a link and a 6-digit code. You open the link, sign in (any method), and type the code. A registration bound to an email needs an account with that address (`403 wrong_account` otherwise).

- It polls `POST /v1/oauth2/token` and gets an API key on your account. Then it starts your plan's free trial the usual way (`POST /v1/billing/plan` returns Stripe Checkout, you enter the card).

| Method | Token | What the agent can do |
| --- | --- | --- |
| Anonymous | At once, by exchanging its identity assertion | Only `account.read` (`GET /v1/account`, `/v1/limits`). Anything else answers `403 claim_required` until you claim it. |
| Email (`service_auth`) | After you sign in and type the code | It must know your email. |

A claimed agent's key has the actions to create and run sandboxes, snapshots and files, read the environment, and `billing.trial`: it can read billing and start the free trial (choose the first plan, you complete Stripe Checkout). It can't change or cancel a plan, pay from the wallet, or create API keys (`403 api_key_action_forbidden`). The agent can exchange its identity assertion for a new key, which revokes the previous one, only while its current key is live. Revoke the key any time on the API keys page, with `POST /v1/oauth2/revoke` or `POST /oauth/revoke`: that ends the agent's registration, so its assertion gets no new key (`invalid_grant`) and it must register and be claimed again. The same goes when the key expires after 90 days. froog doesn't accept identity assertions from agent providers (ID-JAG): sending one answers `issuer_not_enabled`. Claim tokens last 24 hours, codes 10 minutes and 5 tries, and each network (an IPv4 address or an IPv6 /64) can register 10 agents an hour.

## Audit log

The audit log records changes to the account:

- API keys created, rotated and revoked

- Sandboxes created, changed, stopped, started (also by [auto-start](https://froogcorp.com/docs/sandboxes/#auto-start)), forked, restored and deleted

- Snapshots saved and deleted

- Public URLs and their tokens

- Environment variables, secret files and the setup

- Webhooks, SSH keys and OpenTelemetry

- The plan, the wallet, auto top-up and data controls

Each entry says who did it (the API key's id and name, or `session` for the dashboard), when, from which IP, and the request's `X-Request-Id`. What froog does on its own is there too, with the actor `system`: `sandbox.auto_stop`, `sandbox.lifetime_stop`, `sandbox.auto_delete`, `sandbox.disk_expired` (14 days stopped), `sandbox.billing_stop` (the grace period ended, or the trial's hours ran out), `sandbox.abuse_stop`, and plan changes that come from Stripe (`billing.plan_synced`: a renewal, a failed payment, a cancellation. `billing.plan_ended`). It is kept 90 days. Reads and failed requests aren't logged, nor are command runs (see [command history](https://froogcorp.com/docs/history/)).

```sh
$ froog audit --limit 20
$ froog audit --sandbox ci-1 --action sandbox.delete --action sandbox.auto_stop
$ froog audit --export csv > audit.csv      # every entry of the 90 days, oldest first (or ndjson)
WHEN                 ACTION                 TARGET                   BY                         IP
2026-11-12 09:14:03  api_key.revoke         key_3f2a9c1d8e7b         session (you@example.com)  203.0.113.9
2026-11-12 09:02:41  sandbox.create         ci-1                     key_8b1e0c2d4f6a (ci-bot)  198.51.100.7
```

API: `GET /audit?limit=&cursor=` answers `{events: [{id, at, action, target, actor, actor_label, ip, request_id, detail}], next_cursor}`, newest first (`sort=asc`: oldest first). Pass `next_cursor` as `cursor` for the next page. Filters: `action` (exact, comma-separated for several), `sandbox` (entries whose target is the sandbox, or one of its ports or snapshots), and `since` and `until` (unix seconds). `GET /audit/export?format=csv|ndjson` takes the same filters and answers every match as one file, oldest first, with the columns `id, at, action, target, actor, actor_label, ip, request_id, detail`. `detail` holds the harmless parts of the request, such as the size, labels or new name. Values of variables, files, scripts and webhook URLs are never in it. Reading the log needs `account.admin`, and a key limited to some sandboxes gets an empty list.

## Data controls

Three settings decide what froog keeps. Set them for the whole account under **Data & privacy** in the dashboard (an API key can read them, `froog data-retention`, but can't change them: `403 session_required`), or per sandbox when you create it.

| Setting | What froog doesn't keep | Can it be started again? |
| --- | --- | --- |
| **Monitoring off** · `froog new --no-monitoring` | Command history and output, process samples and records, metrics history. `exec` and live job output still work, but history calls answer `409 monitoring_off`. | Yes |
| **Snapshots off** · `froog new --no-snapshots` | Any copy of the disk: no automatic or saved snapshots, no fork or restore (`409 snapshots_off`). A stop erases the sandbox. | No |
| **Zero data retention** (account only) | Anything after a stop: each stop erases the sandbox's disk, snapshots and history, and saved snapshots aren't kept. Turning it on erases every stopped sandbox and saved snapshot right away, so it asks you to type `delete stopped sandbox data`. Running sandboxes still get automatic snapshots. | No |

Account settings apply to sandboxes created from then on. A fork keeps its source's monitoring setting unless you pass `monitoring`. With monitoring off on the account, asking for `monitoring: true` gets `409 monitoring_off_for_account` (likewise `snapshots_off_for_account`). Deleting a sandbox always erases its history. Turning on zero data retention answers with `deletion_operation`, the id of a [deletion operation](https://froogcorp.com/docs/data/#close-account) that follows the erasures until they're done.

### Close your account

`froog account close`, **Close account** under Data & privacy in the dashboard, or `POST /v1/account/close` with the header `X-Froog-Confirm-Close` set to your account's email (without it: `409 confirmation_required`). It needs a full-access key or a dashboard session. Closing does all of this at once:

- It cancels your plan now, not at the end of the period, with no refund. Auto top-up and extra starts turn off.

- It stops every running sandbox. Each stop saves the disk.

- It revokes every API key and every other dashboard session. The session you closed from stays signed in.

- It sends you an email with the date your data is erased.

For 30 days a closed account can sign in, read its account, billing and usage, and reopen. Anything else answers `403 account_closed`. `froog account reopen` (after `froog login`), **Reopen** in the dashboard or `POST /v1/account/reopen` brings it all back except the plan: choose one again to start sandboxes, with no second trial. After 30 days froog erases its sandboxes, snapshots and personal data, as `froog-server admin delete-account` does, and keeps usage and invoice records under the anonymous account id. A wallet that owes money holds the erasure until that's settled.

**Deletion operations.** Closing answers `202` with `{account, operation}`. The operation's id starts with `dop_`. `froog deletion status dop_…` or `GET /v1/deletion-operations/{id}` shows it: `kind` (`account_close` or `zero_data_retention`), `status` (`pending`, `processing`, `blocked`, `completed` or `canceled`), `stage` (`waiting_for_reopen_window`, `removing`, `retrying`, `completed` or `reopened`), `expected_by` (when the erasure is due) and `error`. `blocked` is not final: it's retried every minute.

## Build a platform: a sandbox per user

Products can give each of their users (or each project) a sandbox: persistent files, a coding agent that works inside it, a private preview URL. The terms allow it. The pieces:

- **A key for your backend.** `froog api-key create backend --preset ci` creates, starts, forks and deletes only what it created, and can't touch billing or other keys. Store it as `FROOG_API_KEY`. For a component that serves one user, make a key limited to that user's sandbox: `--sandbox user-42`.

**Nothing of yours in user sandboxes.** Create them with `"environment": false` (no account variables, repos, secret files or in-sandbox `froog` key), and give each one what it needs with its [own variables](https://froogcorp.com/docs/env/): the user's API keys, a tenant id.

```sh
POST /v1/sandboxes
{"name": "user-42", "from": "web-stack", "environment": false,
 "env": {"TENANT_ID": "42", "ANTHROPIC_API_KEY": "<the user's key>"}}
```

- **A template.** Install your stack once in a sandbox and save it (`froog snapshot base --name web-stack`). Every user's sandbox starts from it with `"from": "web-stack"`. Put house rules for the agents (`AGENTS.md`, `CLAUDE.md`, skills, MCP servers) in it too.

- **Know when it's ready.** Create answers `202` at once. The sandbox is usable when its state is `running` (poll `GET /v1/sandboxes/{name}`, or take the `sandbox.running` [webhook](https://froogcorp.com/docs/webhooks/)).

- **Run the agent.** `POST /v1/sandboxes/{name}/prompts {"prompt", "agent", "model"}` queues a task for one of ten [harnesses](https://froogcorp.com/docs/agents/) with the user's keys (a missing key answers `400 provider_not_configured`). Stream its [events](https://froogcorp.com/docs/agents/#conversations) into your UI with `Accept: text/event-stream`. Later prompts continue the same conversation, and `/steer` and `/interrupt` map to your UI's send and stop buttons.

- **Show the result.** `POST /urls {"port": 3000}` gives a private preview URL (it carries a token, so treat it like a password). Use `"public": true` to embed it in an iframe on your site, since browsers may block the private URL's cookie there. Hand files to the user's browser with [signed download and upload URLs](https://froogcorp.com/docs/api/).

- **Keep only what you must.** [Monitoring off](https://froogcorp.com/docs/data/) keeps no command history, and zero data retention erases each sandbox at stop.

### Patterns and cost

Running time is billed per second, only while a sandbox runs ($0.0274 an hour for `default`), and a stopped sandbox costs no running time. So:

- **Always on, one sandbox per project or per user:** the simplest, and $20 a month for a `default` one. A user's projects can share one sandbox, each in its own folder on its own port.

- **Stop when unused:** every hour a sandbox is stopped is an hour you don't pay for, so a user who works two hours a day costs a twelfth of one who keeps it on. Its auto-stop timer (`ttl_secs`, an hour after each start by default) stops it. While the user works, push the deadline back (`PATCH` a new `ttl_secs`, or `froog extend NAME --hours 2`), and start the sandbox when they return, in about 15 seconds. Its disk is kept 14 days (save a snapshot to keep it longer). Processes don't survive a stop: start your dev server again, or run it from the setup script.

- **Disposable per task:** start from the template, run, read the output, delete. You pay only for the seconds it ran: 20 `default` runs of 2 hours each cost $1.10.

Starts are limited to 20 a minute, 100 an hour and 500 a day per account, and an account runs as many sandboxes at once as its plan allows (2, 5, 10 or 25, more on request): `GET /v1/limits` shows where you are.

## Use froog from your coding agent

Claude Code, Codex, Cursor or any agent on your computer can start and drive sandboxes with the `froog` CLI, the same as you. Give it three things: the signed-in CLI, the docs and a short rule that says when to use a sandbox. Or connect froog as an [MCP server](https://froogcorp.com/docs/mcp/) and the agent gets sandboxes as tools.

**1. The CLI.** Install it as above. An agent has no browser, so sign it in with an API key from the dashboard: `echo "$FROOG_API_KEY" | froog login`, or just set `FROOG_API_KEY` in its environment.

**2. The docs.** [froogcorp.com/llms.txt](https://froogcorp.com/llms.txt) is a short index with the key facts, and [froogcorp.com/llms-full.txt](https://froogcorp.com/llms-full.txt) (also at [froogcorp.com/docs.md](https://froogcorp.com/docs.md)) is this whole page as Markdown (about 30,000 tokens). An agent that speaks MCP can search and read them as tools instead, through the docs MCP server: no key, no account.

```sh
$ claude mcp add --transport http froog-docs --scope user https://api.froogcorp.com/docs-mcp
$ codex mcp add froog-docs --url https://api.froogcorp.com/docs-mcp
# or on stdio, from the docs built into the CLI (no network):
$ claude mcp add froog-docs -- froog mcp --docs
```

For Cursor, put `{ "mcpServers": { "froog-docs": { "url": "https://api.froogcorp.com/docs-mcp" } } }` in `~/.cursor/mcp.json`. Its tools: `search_docs` (`query`, `limit` up to 20, default 5: the sections that mention the words, best first, with the matching lines), `read_docs` (`section`: one section in full, by its title or the start of it) and `list_docs`.

**Skills.** The skill pack installs froog skills for Claude Code, Codex, Cursor and the other agents the [skills](https://github.com/vercel-labs/skills) CLI knows:

```sh
$ npx skills add https://froogcorp.com
```

It has `froog-cli` (the everyday commands), `froog-ci` (tests and builds with `froog run`, GitHub Actions), `froog-sandbox-per-user` (a product that gives each user a sandbox through the API), `froog-desktop` (computer use, watching, recording and embedding the desktop) and `froog-parallel-agents` (fork a prepared sandbox, one agent per copy). `--skill froog-ci` installs one. They are published at [froogcorp.com/.well-known/agent-skills/](https://froogcorp.com/.well-known/agent-skills/index.json). To install only the main skill by hand, for Claude Code:

```sh
$ mkdir -p ~/.claude/skills/froog && curl -fsSL https://froogcorp.com/skill.md -o ~/.claude/skills/froog/SKILL.md
```

Other agents that read skills (Codex, Cursor) take the same file in their own skills folder.

**3. A rule.** Put this in `AGENTS.md` (Codex, Cursor and most agents) or `CLAUDE.md` (Claude Code) in your project:

```sh
## Cloud sandboxes (froog)

Use a froog sandbox for builds, tests, untrusted code, Docker and long jobs. Don't run them on this computer.
Docs: https://froogcorp.com/llms.txt. Prices and limits: run `froog limits`; never quote prices from memory.

- Create: `froog new task-1` (returns when SSH works; `--json` prints the sandbox as JSON)
- Run: `froog exec task-1 -- 'cd app && npm test'` (exits with the command's exit code; `--timeout` up to 3600 s)
- Long job or server: `froog exec task-1 --detach -- 'npm run dev'`, then `froog logs task-1 JOB -f`, `froog kill task-1 JOB`
- Past output, also while stopped: `froog history task-1`, `froog history task-1 CMD_ID`
- One-off: `froog run -- npm test` (a fresh sandbox with this folder; deleted after, exits with the command's code)
- Files: `froog push task-1` (this folder to ~/FOLDER), `froog scp -r ./app task-1:app`, or `git clone` inside the sandbox
- Public URL for a port: `froog host task-1 3000`
- Always pass the sandbox name.
- When done: `froog stop task-1` (keeps the disk 14 days) or `froog delete task-1 --yes` (erases it)
```

## Use froog as an MCP server

froog is also an MCP server, so Claude Code, Codex, Cursor, Claude Desktop and any other MCP client can create, run, fork and snapshot sandboxes as tools, without shell commands. There are two ways to connect, with the same tools:

- **Local:** `froog mcp` runs the server on stdio, signed in like the CLI (`froog login`, or `FROOG_API_KEY` in its environment).

- **Hosted:** `https://api.froogcorp.com/mcp` (Streamable HTTP) with an API key in the header `Authorization: Bearer froog_…`, or, for clients that sign in through the browser (Claude's custom connectors, ChatGPT), OAuth: see [Connect with a sign-in](https://froogcorp.com/docs/mcp/#mcp-oauth). Nothing to install.

**Claude Code**

```sh
$ claude mcp add froog -- froog mcp
# or hosted:
$ claude mcp add --transport http froog https://api.froogcorp.com/mcp --header "Authorization: Bearer $FROOG_API_KEY"
```

Add `--scope user` to have it in every project.

**Codex**

```sh
$ codex mcp add froog -- froog mcp
```

Hosted, in `~/.codex/config.toml` (the key is read from `FROOG_API_KEY`):

```sh
[mcp_servers.froog]
url = "https://api.froogcorp.com/mcp"
bearer_token_env_var = "FROOG_API_KEY"
```

**Cursor**: in `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project), one of:

```sh
{ "mcpServers": { "froog": { "command": "froog", "args": ["mcp"] } } }

{ "mcpServers": { "froog": {
    "url": "https://api.froogcorp.com/mcp",
    "headers": { "Authorization": "Bearer ${env:FROOG_API_KEY}" } } } }
```

**Claude Desktop**: Settings, Developer, Edit Config, then in `claude_desktop_config.json` (Claude Desktop doesn't see your shell's `PATH`, so give the full path that `which froog` prints):

```sh
{ "mcpServers": { "froog": {
    "command": "/Users/you/.local/bin/froog",
    "args": ["mcp"],
    "env": { "FROOG_API_KEY": "froog_…" } } } }
```

Any other client: run `froog mcp` as a stdio server, or point it at `https://api.froogcorp.com/mcp` with the `Authorization` header.

### Connect with a sign-in (OAuth)

Claude (claude.ai and Claude Desktop: Settings, Connectors, **Add custom connector**), ChatGPT and other clients that can't send a header connect to `https://api.froogcorp.com/mcp` with just the URL. froog follows the MCP authorization spec (OAuth 2.1 with PKCE):

- The client gets `401` with `WWW-Authenticate: Bearer resource_metadata="https://api.froogcorp.com/.well-known/oauth-protected-resource/mcp"`, reads that and `/.well-known/oauth-authorization-server`, and registers itself (`POST /oauth/register`, dynamic client registration, public clients only, redirect URIs over https, to `localhost`, or an app's own scheme).

- Your browser opens froog's sign-in (Google, as on the dashboard), then a page that names the client and where it sends you back, and asks what it may do: `ci` (the default: create sandboxes, run commands, stop and start them, delete the ones it created), `full-sandbox` (use existing sandboxes only) or `read-only`.

- On **Allow**, the client gets an access token: a new [API key](https://froogcorp.com/docs/api-keys/) with that preset, labeled `mcp: CLIENT NAME`, that expires in 30 days. It's in `froog api-key list` and the dashboard like any key. Revoke it there (or the client calls `POST /oauth/revoke`). Its creation is in the [audit log](https://froogcorp.com/docs/audit/). There are no refresh tokens: after 30 days the client asks you to sign in again.

A malformed authorization request (a bad `response_type`, no PKCE, another `resource`) shows an error page. froog sends the browser back to the client's redirect URI only after you answer the consent page (with `access_denied` on Deny). API keys keep working as bearer tokens on the same URL. Self-hosted control planes turn OAuth on with `FROOG_SERVER_MCP_OAUTH=on` (it needs Google sign-in set up). While it's off, the OAuth URLs answer `404`.

| Tool | Does |
| --- | --- |
| `list_sandboxes` | Sandboxes with state, size, OS and labels. Filter by `label`, `state`, `name_prefix`. |
| `create_sandbox` | A new sandbox (`name`, `size`, `os`, `from` a saved snapshot, `env`, `labels`, `ttl_secs`, `setup`). Waits until it runs. |
| `sandbox_info` | One sandbox in full. |
| `exec` | Run a bash command (`command`, `cwd`, `env`, `timeout_secs` up to 3600). Returns the exit code, stdout and stderr. |
| `start_sandbox`, `stop_sandbox` | Start (waits until it runs) or stop (keeps the disk). |
| `delete_sandbox` | Delete for good. Needs `confirm: true`. |
| `fork_sandbox` | Copy a sandbox, or one of its snapshots, into `new_name`. Waits until the copy runs. |
| `save_snapshot`, `list_snapshots` | Save a snapshot (with a `label`, a template for `create_sandbox` `from`). List a sandbox's snapshots, or the account's saved ones. |
| `read_file`, `write_file`, `list_dir` | Files in a running sandbox: text as is, binary as base64. |
| `host_port` | An HTTPS URL for a port (private with a token unless `public`). |
| `desktop_link` | A 10-minute link to watch and use the desktop. |
| `prompt_agent` | Run a coding agent inside the sandbox on a prompt (`agent`, `model`, `effort`) and return its answer. |
| `start_job` | Start a bash command in the background (`command`, `cwd`, `env`), like `froog exec --detach`. Returns the job id at once. |
| `list_jobs`, `job_logs`, `kill_job` | A sandbox's background commands. One job's state, exit code and output from `offset` (pass the returned `next_offset` to read only what's new). Stop one. |

- `create_sandbox`, `fork_sandbox` and `start_sandbox` wait up to 120 seconds (`wait: false` returns at once). `exec` and `read_file` return at most 256 KiB of output (the end of a long output, the start of a long file).

- A failed call is a tool error whose text is the API's error, `{"error":{"code":…,"message":…,"request_id":…}}`, so the agent sees why (`not_found`, `api_key_action_forbidden`, a full plan) and you can quote the request id.

- **Give the agent a scoped key.** The tools obey the key's [scope](https://froogcorp.com/docs/api-keys/) exactly like the HTTP routes they stand for: with `froog api-key create agent --preset ci`, an agent can create sandboxes and delete only the ones it created. With `--preset read-only`, it can only look.

- **Output as it runs.** An `exec` call that sends `_meta.progressToken` gets its output as `notifications/progress` while the command runs (`progress`: the bytes so far, `message`: the new output), then the usual result. `froog mcp` writes them on stdout. The hosted endpoint answers that call as server-sent events when the client accepts `text/event-stream`.

- The hosted endpoint is stateless: each `POST` gets one JSON answer (or, for a call with a progress token, one event stream that ends with the answer), with no session id (`GET` answers 405). It speaks MCP 2025-06-18, 2025-03-26 and 2024-11-05. Tool calls count toward the same per-IP request limit as the API.

## Agent frameworks

To use a sandbox as the code-execution tool of an agent you build, give the agent three tools bound to one sandbox: run a command (`POST /sandboxes/{name}/exec`), write a file and read a file (`PUT` and `GET /sandboxes/{name}/files/content`). Keep one sandbox per conversation or user, named after its id, and stop it when the conversation pauses (or let its auto-stop timer do it). Complete, runnable guides built on the [SDKs](https://froogcorp.com/docs/sdks/):

- [OpenAI Agents SDK](https://froogcorp.com/integrations/openai-agents-sdk/) (Python)

- [Claude Agent SDK](https://froogcorp.com/integrations/claude-agent-sdk/) (Python and TypeScript)

- [Vercel AI SDK](https://froogcorp.com/integrations/vercel-ai-sdk/) (TypeScript)

- [LangChain and LangGraph](https://froogcorp.com/integrations/langchain/) (Python)

- [Mastra](https://froogcorp.com/integrations/mastra/) (TypeScript)

- [Harbor and Terminal-Bench](https://froogcorp.com/integrations/harbor/) (Python): evals, one saved snapshot per task environment, one sandbox per trial

- [AgentEnv](https://froogcorp.com/integrations/agentenv/) (Python): a `froog` sandbox provider for RL environments

All of them are listed at [froogcorp.com/integrations](https://froogcorp.com/integrations/). Any other framework that calls functions as tools works the same way.

## Recipes

### A coding agent on your repo

Set things up once for your account, then every new sandbox starts with the repo cloned, dependencies installed and the agent's key in place:

```sh
$ froog env set ANTHROPIC_API_KEY                        # for Claude Code
$ froog env set GH_TOKEN                                 # a GitHub token that can push; git and gh use it
$ froog environment add-file .gitconfig ~/.gitconfig     # your name and email for commits
$ froog environment add-repo https://github.com/you/app
$ echo 'cd ~/app && npm ci' | froog environment setup -

$ froog new fix-login
$ froog jobs fix-login                                   # the setup job clones and installs; wait until it has exited
$ froog prompt fix-login "In ~/app: fix the login redirect bug, run the tests, commit on a new branch, push it and open a pull request with gh"
$ froog stop fix-login                                   # or froog delete fix-login -y
```

`froog prompt` follows the agent while your terminal stays connected. The agent keeps working if you disconnect (`--detach` doesn't wait at all), and `froog events NAME` shows what it did. You can also run an agent as a [background command](https://froogcorp.com/docs/jobs/) and read its raw output later, even after the sandbox has stopped ([command history](https://froogcorp.com/docs/history/)):

```sh
$ froog exec fix-login --detach -- 'cd ~/app && claude -p "fix the login redirect bug and open a PR" --dangerously-skip-permissions'
$ froog logs fix-login JOB -f
```

The agent has root in its sandbox and can read every variable and secret file you gave it, so give it tokens that can do only what the task needs (for example a fine-grained GitHub token for one repo).

### Parallel agents

Prepare one sandbox, save it, start several copies, and give each one a task (or the same task with different agents):

```sh
$ froog new base
$ froog exec base -- 'git clone https://github.com/you/app && cd app && npm ci && npm test'
$ froog snapshot base --name app-ready
$ froog new try --from app-ready --count 3               # try-1, try-2, try-3

$ froog prompt try-1 "In ~/app: make the search page faster" < /dev/null > try-1.log 2>&1 &
$ froog prompt try-2 "In ~/app: make the search page faster" --agent codex < /dev/null > try-2.log 2>&1 &
$ froog prompt try-3 "In ~/app: make the search page faster" --agent gemini < /dev/null > try-3.log 2>&1 &
$ wait

$ froog exec try-2 -- 'cd app && git diff --stat && npm test'   # compare
$ froog delete try-1 -y && froog delete try-3 -y                # keep the best
```

Each copy has its own disk and starts with your [variables](https://froogcorp.com/docs/env/), so the agents' keys are there. Programs don't carry over from the snapshot: start servers again in each copy. How many run at once depends on your plan (2, 5 or 10). Forking a running sandbox (`froog fork base try --count 3`) works too, without saving a snapshot first. Agents that use the desktop share one screen per sandbox, so give each GUI task its own sandbox.

## VS Code and Cursor over SSH

VS Code's and Cursor's Remote-SSH extensions open a folder in a sandbox as if it were local. `froog ssh-config` adds your sandboxes to `~/.ssh/config` as `froog-NAME`:

```sh
$ froog ssh-config                 # all your sandboxes; or name some: froog ssh-config my-box
$ ssh froog-my-box                 # plain ssh, scp, rsync and git work too
```

In VS Code or Cursor, run **Remote-SSH: Connect to Host**, pick `froog-my-box`, and open `/home/user/app` (or any folder). SSH uses your own key, the one `froog login` registered (see `froog keys list`).

The entry keeps working across starts, although a stopped sandbox can come back with a new address and port. It doesn't hold an address: `ProxyCommand froog ssh-proxy %n` asks the API for the sandbox's current address at each connection, starts the sandbox first if it's stopped (billed like any start, `--no-start` writes entries that fail instead), records its host key in froog's own known-hosts file, and carries the SSH connection. The block froog writes, at the top of the file between `# BEGIN froog` and `# END froog` (run it again to update it):

```sh
# BEGIN froog (written by `froog ssh-config`; edits inside are replaced)
Host froog-my-box froog-api froog-*
    User user
    ProxyCommand "/usr/local/bin/froog" ssh-proxy %n
    UserKnownHostsFile "/home/you/.config/froog/known_hosts"
    StrictHostKeyChecking yes
    ServerAliveInterval 30
# END froog
```

The `froog-*` pattern covers sandboxes you create later. Editors only list the names on that line, so run `froog ssh-config` again after creating one. `--print` prints the block instead of writing it, `--file PATH` writes another file, and `--remove` takes the block out.

- **Auto-stop counts from the start, not from activity.** A sandbox stops its timer's length after it starts (1 hour by default), even with an editor connected. `froog extend my-box --hours 8` moves it to 8 hours from now, and `froog extend my-box --no-auto-stop` removes it.

- froog's known-hosts file is `~/.config/froog/known_hosts` (`$XDG_CONFIG_HOME/froog` if set). On Windows it is `%APPDATA%\froog\known_hosts`.

- Ports the editor forwards for you work like `froog forward`. For a URL someone else can open, use [`froog host`](https://froogcorp.com/docs/urls/).

## Private registries

Give every sandbox your registry credentials with account [variables](https://froogcorp.com/docs/env/) and [secret files](https://froogcorp.com/docs/environment/). Both are encrypted at rest on froog and written into each sandbox at every start (secret files with mode 600), so changes reach a running sandbox on its next start. A sandbox created with `--no-environment` gets none of them, and per-sandbox variables (`-e KEY=VALUE`) reach one sandbox only.

| Registry | Set up |
| --- | --- |
| npm, pnpm (npmjs, GitHub Packages, Artifactory…) | A `.npmrc` secret file that reads the token from a variable: `@you:registry=https://npm.pkg.github.com` and `//npm.pkg.github.com/:_authToken=${NPM_TOKEN}`, then `froog env set NPM_TOKEN`. |
| pip, uv | `froog env set PIP_INDEX_URL` (or `PIP_EXTRA_INDEX_URL`) and `UV_DEFAULT_INDEX`, with the credentials in the URL (`https://user:token@pypi.example.com/simple`). Or a `.config/pip/pip.conf` secret file. |
| Docker (GHCR, Docker Hub, ECR…) | Log in from the [setup script](https://froogcorp.com/docs/environment/), which runs at every start with your variables: `echo "$GHCR_TOKEN" \| docker login ghcr.io -u you --password-stdin`. A `.docker/config.json` secret file works too, if its `auths` hold the credentials (Docker Desktop on a Mac keeps them in the keychain instead). |
| Cargo | The registry in a `.cargo/config.toml` secret file, and its token as `froog env set CARGO_REGISTRIES_NAME_TOKEN`. |
| Go modules | `froog env set GOPRIVATE` (e.g. `github.com/you/*`) plus `GH_TOKEN`: setup runs `gh auth setup-git`, so `go get` fetches private GitHub repos. Other hosts: a `.git-credentials` secret file. |
| Maven, Gradle | A `.m2/settings.xml` or `.gradle/gradle.properties` secret file. |

Variables are loaded by login shells, `froog exec`, background commands, the setup script and agent prompts. A service you run with systemd doesn't load `~/.froog/env` on its own. The registry must be reachable at a public address: sandboxes can't reach private networks (see [Network](https://froogcorp.com/docs/network/)), and their outbound address is neither fixed nor theirs alone, so authenticate with tokens rather than an IP allowlist.

## Network and outbound traffic

A sandbox can reach the internet. The internet can reach it through SSH, froog's [public URLs](https://froogcorp.com/docs/urls/), its own public IPv6 address, which answers SSH and the ports you open, and the ports you open over IPv4, each at a port of its own on the machine's shared address. Other inbound connections are refused. In detail:

| Outbound | Open to the internet on any port, TCP and UDP, except outbound SMTP on TCP port 25, which is dropped. Send email through a provider's API or submission port (587 or 465). No egress charges. |
| --- | --- |
| Blocked | Other sandboxes, the machine the sandbox runs on, and private and internal ranges: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16` and `100.64.0.0/10`, and over IPv6 the machine's own range, `fc00::/7`, `fe80::/10` and multicast. |
| Outbound address | IPv4 traffic leaves from the public IPv4 address of the machine the sandbox runs on, shared with the other sandboxes on it. IPv6 traffic leaves from the sandbox's own IPv6 address. froog has no dedicated or static outbound IPv4 addresses. |
| IPv6 address | Each sandbox has its own public IPv6 address (`ipv6` in the API, `froog info`). It stays the same while the sandbox runs and across a [warm resume](https://froogcorp.com/docs/snapshots/#warm). A start after a longer stop, and a fork, get a new one, so look it up after each start rather than saving it. A sandbox made before IPv6 was added gets one at its next start. |
| Inbound | Over IPv4: SSH (keys only, at the address and port `froog info` shows), the ports you open (each at its own public port of the machine's address, see [Open ports](https://froogcorp.com/docs/network/#ports)), and replies to the sandbox's own connections. Over IPv6: SSH on port 22 (keys only), ping, replies, and the ports you open. Every other port is refused by froog's firewall, so a server listening on all addresses stays private until you open its port. For HTTPS with a hostname, use `froog host NAME PORT`: froog's edge reaches the port through an SSH tunnel and works for visitors without IPv6. |
| Open ports | `froog port open NAME 27015/udp` opens a raw TCP or UDP port (TCP when you leave out the protocol), for a game server, a database or anything else that isn't HTTPS. `froog port close NAME 27015/udp` closes it, and `froog port list NAME` lists what's open. The rule is in froog's firewall, outside the sandbox, so it works the same on every OS and needs no firewall setup inside. Open ports stay open across stops, starts and restores, and a fork gets the same ones. A `--public` URL also opens its TCP port on IPv6. At most 64 per sandbox, public URLs' ports included. Inside a sandbox: `froog port open "$(hostname)" PORT`. API: `GET`, `PUT` and `POST /v1/sandboxes/{name}/ports`, `DELETE /v1/sandboxes/{name}/ports/{port}/{protocol}` (an API key needs `sandbox.update` to change them). |
| Open ports over IPv6 | On the sandbox's own IPv6 address, at the same port number: `[2a01:4f8:…::1:2]:27015`. |
| Open ports over IPv4 | Sandboxes share the IPv4 address of the machine they run on, so each port you open also gets a public port of its own on that address, forwarded to your port. `froog port list NAME` shows it, for example `27015/udp at 203.0.113.7:40002`, and so do `froog info` and `ipv4` in the API (`address` is what to connect to). The public port is a different number than yours, from 40000 to 49999. TCP and UDP of one port get the same number when it's free. A stop and start on the same machine gets the same one back (a [warm resume](https://froogcorp.com/docs/snapshots/#warm) always does). A start on another machine gets a new one, so look it up after each start rather than saving it. A stopped sandbox's public ports can go to another sandbox if the machine runs out. A public URL's port gets none: IPv4 visitors use the URL. At most 16 per sandbox, and 64 at once across an account's running sandboxes. Ports past either limit, or opened when the machine has none free, stay on IPv6 only, and `froog port list` says why (`ipv4_note`). Each sandbox takes at most 4,096 tracked inbound IPv4 connections at once. More new ones are dropped. |
| DNS | Sandboxes resolve through 1.1.1.1 and 8.8.8.8. |
| Your computer | A sandbox has no route back to it. `froog forward NAME PORT --reverse` opens one port over SSH while it runs. |

### Restricting outbound traffic

froog doesn't filter outbound traffic beyond the rules above. You have root in the sandbox, so you can add your own rules with nftables. Rules made by `user` can be undone by anything running as `user`, since it has passwordless `sudo`. To contain code you don't trust, run it as a second user without `sudo` and outside the `docker` group, and limit that user. This allows it only DNS and HTTPS:

```sh
$ froog ssh my-box
user@my-box:~$ command -v nft || sudo apt-get install -y nftables   # sudo pacman -S nftables on Omarchy
user@my-box:~$ sudo useradd -m -s /bin/bash agent
user@my-box:~$ sudo nft -f - <<EOF
table inet egress {
  chain out {
    type filter hook output priority 0; policy accept;
    meta skuid $(id -u agent) oifname "lo" accept
    meta skuid $(id -u agent) udp dport 53 accept
    meta skuid $(id -u agent) tcp dport 443 accept
    meta skuid $(id -u agent) drop
  }
}
EOF
user@my-box:~$ sudo -u agent -H bash -lc 'cd ~ && ./run-task.sh'
```

The rules last until the sandbox stops: every start boots afresh. To apply them at every start, put the commands in your [setup script](https://froogcorp.com/docs/environment/) (it runs in the background at every start, so the rules apply a moment after the sandbox is up). The second user can't read `~/.froog` (mode 700, with your variables and setup files) or the files froog writes with mode 600 (secret files, and the in-sandbox key in `~/.config/froog/config.json`). To allow a list of hosts rather than ports, send that user's traffic through an HTTP proxy that checks host names, and allow only the proxy.

## Security and isolation

### What separates sandboxes

- **A VM each.** Every sandbox is a QEMU/KVM virtual machine with its own kernel (the distribution's own), memory and disk. Sandboxes share a physical machine with other sandboxes, but no kernel.

- **A confined VM process.** Each VM's QEMU process runs as an unprivileged user with no capabilities, under QEMU's seccomp sandbox (it can't start programs or gain privileges), in a systemd unit that makes the system read-only, lets it write only its own VM's directory and gives it only `/dev/kvm`, `/dev/net/tun` and `/dev/vhost-net`.

- **A separate network port.** Each VM has its own port on the machine's network bridge. It can't reach other sandboxes, the machine itself or private ranges, can only send traffic from its own addresses (no spoofing), and can't send router advertisements. Inbound connections to its public IPv6 address are refused except SSH and the ports you open. See [Network](https://froogcorp.com/docs/network/).

- **Its own disk.** A new sandbox's disk is a copy of froog's base image, which is cleaned of keys, machine ids and logs before it's saved. Snapshots, forks and saved snapshots are copies of one sandbox's disk.

- **Shared CPU and memory.** vCPUs are shared with other sandboxes on the machine. To fit more VMs, memory pages that are identical across VMs are stored once (KSM), and memory a guest frees goes back to the machine.

### Inside a sandbox

You log in as `user`, which has passwordless `sudo`: you and your agents are root in your own VM. `froog prompt` runs each harness with its permission prompts off (for example Claude Code with `--dangerously-skip-permissions`, Codex with `--dangerously-bypass-approvals-and-sandbox`), because the VM is the boundary. Anything that runs in a sandbox, including code an agent downloads, can read everything in it: your [variables](https://froogcorp.com/docs/env/) (in `~/.froog/env`), [secret files](https://froogcorp.com/docs/environment/), logins made inside it and its `froog` key. Give a sandbox only the credentials its work needs, and use `--no-environment` for code or people you don't trust.

`--no-environment` decides what froog writes into a sandbox at start. It doesn't clean a disk you start from: a fork, or a sandbox from a saved snapshot, has everything that was on the source's disk, secret files and logins included. Make templates for untrusted code from a sandbox that never held credentials.

### Who can get in

- **SSH:** keys only. Password and root logins are off. Your account's SSH keys (`froog keys`) are written to `~/.ssh/authorized_keys` at every start, and froog rewrites the file on running sandboxes when you add or remove a key, so a removed key stops working at once (a stopped sandbox gets the new list when it starts). froog owns the file: it replaces it whole, so keys you add to it by hand are dropped at the next rewrite. Register them with `froog keys add`. Each sandbox has its own SSH host key, made by froog and pinned by the CLI, so `froog ssh` refuses a machine that isn't yours.

- **API keys:** each has [scopes](https://froogcorp.com/docs/api-keys/) and an expiry. froog stores only a SHA-256 hash of each key.

- **The in-sandbox key** reaches only its own sandbox and can't create, start, fork or delete sandboxes. It's replaced at every start and revoked when the sandbox stops.

- **froog's control plane** holds a key of its own for each sandbox (also in `authorized_keys`) and uses it for `exec`, files, background commands, setup, public URLs and the desktop. Each command, with its `env` values and any tokens, goes over the SSH session's input, never on a command line other processes could read. froog's operators run the machines and can reach any sandbox.

- **Public URLs** are private by default (a token, then a cookie for that host) and live on a separate domain, `froogusercontent.com`, so a page in a sandbox never shares cookies with froogcorp.com. **Desktop links** carry their token after the `#` and must be opened within 10 minutes.

### Secrets and data

Account and per-sandbox variables, secret files, the setup script, telemetry headers and each sandbox's SSH keys are encrypted at rest on the control plane (XChaCha20-Poly1305). Inside a sandbox, variables and secret files are plain files on its disk, and so in its snapshots.

| Data | Kept |
| --- | --- |
| A running sandbox's disk | Until you stop or delete it. Automatic snapshots every hour and at each stop, the 3 newest kept. |
| A stopped sandbox's disk | 14 days, then deleted (`snapshot_data_expired`). |
| Saved snapshots | Until you delete them, also after their sandbox is deleted. |
| Command history and output | 90 days, up to 512 MiB per account. Deleted with the sandbox. Not kept at all with [monitoring off](https://froogcorp.com/docs/data/). |
| Process samples and metrics history | 7 days. Deleted with the sandbox. |
| Process records | 72 hours after the process ends. Deleted with the sandbox. |
| A deleted sandbox | Its VM, disk and automatic snapshots are deleted. |

Snapshots live on the machine that runs the sandbox, and are also copied off that machine to separate storage, encrypted, usually within minutes of being taken: saved snapshots, the snapshot taken when a sandbox stops, and the newest automatic snapshot of a running one. If the machine fails, a sandbox comes back from that copy. Changes made since the last snapshot are only on the machine, so keep work you can't lose in git or download it. With [zero data retention](https://froogcorp.com/docs/data/) nothing is copied. The control plane's database (accounts, encrypted secrets, command history) is backed up every hour, encrypted, to storage in another location, and backups are kept up to 12 months. [Data controls](https://froogcorp.com/docs/data/) turn off history, snapshots, or everything after a stop.

Report a security problem to [hello@froog.tech](mailto:hello@froog.tech), and abuse coming from a sandbox to the same address with "Abuse" in the subject.

## Concepts, coming from other sandboxes

| froog | What it is | Similar to |
| --- | --- | --- |
| Sandbox | A full Linux VM with its own disk. It runs until you stop it or its auto-stop timer does (1 hour after each start by default, up to 30 days, or never), with no session limit. | A sandbox at E2B, Daytona or Modal |
| Saved snapshot (`new --from`) | A named copy of a disk that new sandboxes start from. Make one by setting up a sandbox and saving it, not from a Dockerfile. | An E2B template, a Daytona snapshot, a Modal image |
| Fork | A new sandbox from any snapshot of another one, or from its current state. | Creating from a snapshot |
| Stop and start | A stop snapshots the disk and ends the VM, and billing stops. A start within 10 minutes resumes with programs running. Later starts boot from the disk. | Pause and resume |
| The disk | Persists across stops and starts. There are no separate volumes. | A volume |
| `exec`, background commands | Run a command and wait for it (up to 1 hour), or start it in the background and follow its output. | Running commands in a sandbox |
| Environment | Variables, repos, secret files and a setup script that every new sandbox in your account starts with. | Environment variables and secrets |
| Public URL (`froog host`) | An HTTPS address for a port, private with a token unless public. | Preview links, tunnels |

Comparisons with prices and limits: [E2B](https://froogcorp.com/vs/e2b/), [Daytona](https://froogcorp.com/vs/daytona/), [Fly.io Sprites](https://froogcorp.com/vs/sprites/) and [Modal](https://froogcorp.com/vs/modal/). Coming from Runloop? See [Switching from Runloop](https://froogcorp.com/blog/switching-from-runloop/), a step-by-step migration guide.

## CLI reference

| `froog login` | Log in: shows a code and opens the [dashboard](https://froogcorp.com/dashboard/), where you type the code and approve it (on a remote machine, open the printed link anywhere). Then registers your SSH public key. `--paste` asks for an API key from the dashboard instead, and `--key KEY` or a key on stdin works for scripts. |
| --- | --- |
| `froog new [NAME]` | Start a sandbox. `--os ubuntu\|debian\|omarchy`, `--size`, `--ttl 4h` (auto-stop that long after each start, default 1h, at most 30d), `--no-auto-stop`, `--count N`, `--no-wait`, `--setup-file FILE`, `--no-environment`, `-e KEY=VALUE`, `--host PORT[:public]` (a URL for a port), `--file PATH=LOCAL` (written before it's ready), `--repo URL[#BRANCH]` and `--repo-dir DIR` (a repo for this sandbox), `--max-lifetime 2h` (stop that long after each start), `--delete-after 1d` (delete after staying stopped that long), `--expires 3h` (delete that long after creation, whatever its state), `--auto-start` (start on a URL request or an API call while stopped), `--environment NAME` (one of your [environments](https://froogcorp.com/docs/environment/#environments)), `--visibility shared\|private` (in an [organization](https://froogcorp.com/docs/organizations/#org-sandboxes)), `--from SAVED\|fsh_…` (a saved snapshot, or a [share code](https://froogcorp.com/docs/snapshots/#share), `--from-share CODE` too, from a share code it starts without your environment unless you pass `--environment NAME`). |
| `froog run -- CMD` | A new sandbox with this folder pushed to it: run the command, print the cost, delete the sandbox, exit with the command's code (Ctrl-C deletes it too). `--push DIR`, `--no-push`, `--cwd DIR`, `--timeout SECS` (exit 124), `--from SAVED`, `--size`, `-e KEY=VALUE`, `--no-env`, `--keep` (keep it, with snapshots, until its 15-minute auto-stop), `--json`. See [one-off runs](https://froogcorp.com/docs/jobs/). |
| `froog push NAME [DIR]` | Upload a folder (default: this one) to `~/FOLDER`: git-tracked and not-ignored files. `--to PATH` puts it elsewhere. |
| `froog list` · `froog status NAME` | Show sandboxes, with what each cost this billing period. `--label KEY[=VALUE]`, `--prefix` and `--state` filter the list. |
| `froog label NAME KEY=VALUE KEY-` | Set labels, or remove one with `KEY-`. `new` and `fork` take `--label KEY=VALUE`. |
| `froog ssh NAME [-- CMD]` | Open a shell, or run a command over SSH. |
| `froog scp SRC DEST` | Copy files: `froog scp ./f box:~/` or `froog scp box:~/f .`, with `-r` for directories. |
| `froog exec NAME -- CMD` | Run a command through the API (no SSH needed), printing its output as it arrives. `--timeout SECS` (up to 3600, past it the command is stopped and `froog exec` exits 124). `--detach` runs it in the background, and `--timeout 0` streams a background run to the end. `--cwd DIR`, `-e KEY=VALUE` for this command only. A pipe or file on stdin is sent as the command's input (up to 1 MiB). `--no-stdin` skips it. |
| `froog jobs NAME` · `froog logs NAME JOB [-f]` · `froog kill NAME JOB` · `froog stdin NAME JOB [TEXT] [--close]` | List, read or follow, and stop background commands. Write to the input of one started with `--detach --stdin-open`. |
| `froog wait NAME --port P` | [Wait](https://froogcorp.com/docs/urls/#wait-port) until something listens on the port inside the sandbox. `--timeout 2m` (default 60s, at most 10m). Exits 1 on a timeout. |
| `froog audit` | The account's [audit log](https://froogcorp.com/docs/audit/), newest first. `--limit N`, `--cursor C` for older entries, `--action A` (repeat), `--sandbox NAME`, `--export csv\|ndjson` for everything as a file. |
| `froog info NAME` | Everything about one sandbox: state, address, auto-stop timer, last reboot, disk use, a refused stop, disk retention, setup status, cost this period, snapshots, public URLs. (`froog status NAME` does the same.) |
| `froog status [--check]` | API health, the signed-in account, and where the config and CLI live. `--check` exits 1 when not signed in or the API can't be reached. |
| `froog telemetry [set URL -H K=V \| unset]` | [OpenTelemetry export](https://froogcorp.com/docs/telemetry/) of metrics and events. |
| `froog data-retention` | Show the account's [data controls](https://froogcorp.com/docs/data/). `froog new --no-snapshots` / `--no-monitoring` set them per sandbox. |
| `froog feedback "TEXT"` | Send feedback to the froog team (or pipe it in). Replies go to your account email. 5 a minute, 30 a day. |
| `froog api-key list\|create\|rotate\|revoke\|actions` | API keys with [scopes](https://froogcorp.com/docs/api-keys/): `create NAME --preset read-only\|full-sandbox\|ci\|admin` or `--actions`, `--sandbox NAME`, `--label KEY=VALUE`, `--env NAME` (only that [environment](https://froogcorp.com/docs/api-keys/#env-keys)'s sandboxes), `--ttl 30d\|never`. |
| `froog history NAME [CMD_ID] [--stderr] [-f]` · `froog ps NAME [--history [--since 6h]]` | Past commands and their output (90 days, also while stopped) and processes, busiest first (with `--history`, the samples taken every 30 seconds, kept 7 days, with `--records [--running]`, every process seen, kept 72 hours). |
| `froog alerts [on\|off KIND...]` | Which [emails](https://froogcorp.com/docs/sandboxes/#alerts) the account gets (`disk`, `stop_refused`, `unresponsive`, `rebuilt`, `plan_nudge`, `billing`), and `auto-rebuild`. |
| `froog metrics NAME` | CPU, memory, disk and network of a running sandbox, read now. `--history [--since 6h]`: the 30-second samples kept 7 days. |
| `froog prompt NAME "TEXT"` | Give a coding agent a task and print its work until the turn ends (exit 0 done, 1 failed, 130 interrupted). `--agent claude\|codex\|gemini\|opencode\|pi\|amp\|cursor\|prime\|kimi\|mistral`, `--model`, `--effort`, `--fast`, `--new`, `--conversation ID`, `--attach FILE`, `--detach`, `--timeout SECS`, `--list-models`. |
| `froog conversations NAME [ID]` | Agent conversations, or one's turns. `--delete`. |
| `froog events NAME` | What the agents did. `--follow`, `--conversation ID`. |
| `froog steer NAME "TEXT"` | Send a message to the running turn. `--conversation ID`. |
| `froog interrupt NAME` | Stop running turns, cancel queued prompts. `--conversation ID`. |
| `froog ai` | AI credentials ([details](https://froogcorp.com/docs/ai-proxy/)): `proxy [on\|off]`, `profiles`, `profile-new\|profile-set\|profile-rm`, `key-add PROVIDER\|NAME [--base-url URL [--secret-header H [--secret-prefix P]\|--secret-query Q]] [--profile P]`, `key-rm ID`, `switch ID`, `env ENV [PROFILE]`, `use NAME PROFILE`, `usage [NAME] [--days N]`, `block\|unblock NAME\|--all`. |
| `froog subscriptions` | Claude and ChatGPT sign-ins: `login claude\|chatgpt --sandbox NAME`, `logout claude\|chatgpt`. |
| `froog host NAME [PORT] [--public] [--subdomain S] [--title T] [--rm]` | An HTTPS URL for a port, private (with a token) unless `--public`. Without PORT, list them. |
| `froog port open\|close NAME PORT[/tcp\|udp]`, `froog port list NAME` | Open or close a raw port on the sandbox's IPv6 address (TCP unless `/udp`), kept across stops and starts. See [Network](https://froogcorp.com/docs/network/). |
| `froog url-token create\|list\|revoke NAME PORT` | Extra tokens for a private URL, one per viewer: `create --ttl 2h --label alice` prints the URL with it, `revoke NAME PORT ID` cuts one off. |
| `froog forward NAME PORT` | Reach a port of the sandbox on `localhost` through SSH, e.g. `froog forward box 3000`, or `8080:3000` for local:remote. `--bind 0.0.0.0` (or another local address) lets other machines on your network reach it. `--reverse`: a port on this computer inside the sandbox instead (`--bind` then sets the address inside the sandbox). Reconnects when the sandbox comes back on a new address. The sandbox still stops at its [auto-stop](https://froogcorp.com/docs/sandboxes/#auto-stop) deadline. |
| `froog ssh-config [NAME…]` | Add your sandboxes (default: all) to `~/.ssh/config` as `froog-NAME`, for plain `ssh`, scp and VS Code or Cursor. Each connection finds the current address and starts a stopped sandbox. `--no-start`, `--print`, `--file PATH`, `--remove`. See [VS Code and Cursor](https://froogcorp.com/docs/editor/). |
| `froog desktop NAME` | Open the desktop in your browser. `--theme dark\|light`, `--print` prints the link, and `--tunnel` uses an SSH tunnel instead. |
| `froog browser NAME` | Open only Chrome (no desktop) in your browser. `--profile /abs/dir` picks the Chrome profile, and `--print` and `--theme` work as for `desktop`. |
| `froog snapshot NAME` | Save a snapshot. `--name LABEL` names it, `--delete SNAPSHOT` deletes one. |
| `froog snapshot share\|shares\|unshare SAVED` | [Share codes](https://froogcorp.com/docs/snapshots/#share) of a saved snapshot: make one (shown once), list them, revoke one (`unshare SAVED shr_…`). |
| `froog snapshot ls\|pull SNAPSHOT [PATH]` | [Files inside a snapshot](https://froogcorp.com/docs/snapshots/#snapshot-files) (a saved snapshot's name or `SANDBOX@SNAPSHOT`), without starting it. `pull -o DIR`. Default all of `~`. |
| `froog snapshots [NAME]` | A sandbox's snapshots, or without NAME, your saved snapshots. `--rm SAVED` removes a saved snapshot. |
| `froog fork NAME[@SNAP] NEW` | New sandbox from a snapshot (default: the current state). `--count N`, `--size`, `-e KEY=VALUE` (on top of the source's own variables), `--ttl 1h` (default: the source's auto-stop timer), `--no-snapshots` (a stop erases the copy), `--no-environment` (the copy runs without your environment, [your logins removed](https://froogcorp.com/docs/environment/#scrub)), `--environment NAME` (that environment's latest version instead of the source's). |
| `froog restore NAME@SNAP` | Roll back in place. `NAME@latest` uses the newest snapshot, e.g. to recover a sandbox in the error state. |
| `froog stop NAME` · `--all` · `--force` | Run `~/.froog/pre-stop`, snapshot and stop billing. `--force` stops even if the snapshot fails, keeping the last good one (see [Stopping safely](https://froogcorp.com/docs/sandboxes/#stopping)). `--no-wait` prints the stop's id, and `froog stop --status ID` shows it (pending, failing, completed, superseded or failed). |
| `froog start NAME` | Resume from the latest snapshot. `--size` starts it at another size, `--ttl 2h` sets a new auto-stop timer, counted from this start (`0` for none), `--no-environment` boots without your environment, [your logins removed](https://froogcorp.com/docs/environment/#scrub) (`--environment` with it again, `--environment NAME` onto that environment's latest version), `-e KEY=VALUE` adds a variable of its own. These stay for later starts. |
| `froog rename NAME NEW` | Rename a sandbox (its hostname changes on its next start). |
| `froog extend NAME [TTL]` | Move the [auto-stop](https://froogcorp.com/docs/sandboxes/#auto-stop): `--hours 12` (or `12h`) stops it 12 hours from now, and later starts get 12 hours too. `--no-auto-stop` (or `0`) turns it off. `--max-lifetime` and `--delete-after` change those (`0` removes them), and `--expires 1h` sets the [deadline](https://froogcorp.com/docs/sandboxes/#expires) to an hour from now (`0` removes it). `--auto-start` / `--no-auto-start` turn [auto-start](https://froogcorp.com/docs/sandboxes/#auto-start) on or off. |
| `froog delete NAME` | Delete a sandbox and its automatic snapshots. Its saved snapshots stay (`froog snapshots`). `-y` skips the prompt. |
| `froog mcp` | An [MCP server](https://froogcorp.com/docs/mcp/) on stdio with this CLI's login, for Claude Code, Codex, Cursor and Claude Desktop: `claude mcp add froog -- froog mcp`. `--docs`: the [docs MCP server](https://froogcorp.com/docs/ai-tools/) instead (no login). |
| `froog inbox create\|list\|rm\|messages\|read\|wait\|attach\|detach\|billing` | Tokenmail inboxes for agents, $2 a month each: `wait ADDRESS --code` prints the next one-time code (`--link`: the verification link). |
| `froog webhooks list\|add\|update\|deliveries\|rm\|rotate\|test` | Signed notifications of sandbox state changes, and their delivery log. |
| `froog env list\|set\|rm` | Environment variables every new sandbox gets. `set NAME` without a value reads it from stdin. |
| `froog environment show\|add-repo\|rm-repo\|repo-setup\|setup\|add-file\|rm-file` | Repos, secret files and setup scripts for every new sandbox. Set the script with `setup FILE` (or `-` for stdin, `--clear` to remove). `repo-setup REPO FILE` for one repo. `--env NAME` works on another environment than the default. |
| `froog environment list\|create\|rm\|rename\|default\|versions\|upgrade` | [Named environments](https://froogcorp.com/docs/environment/#environments) and their [versions](https://froogcorp.com/docs/environment/#versions): `create NAME [--copy-from ENV] [--default]`, `upgrade NAME [--sandbox S]` (also `froog env upgrade NAME`). |
| `froog keys list\|add\|rm` | Manage the SSH keys allowed into your sandboxes. |
| `froog github` · `connect` · `repos` · `token-repos [OWNER/NAME…]` · `disconnect ID` | The [GitHub connection](https://froogcorp.com/docs/github/): install the app, list repositories, limit the token in sandboxes, unlink. |
| `froog ci` · `job ID` · `logs ID` · `public on\|off [--users a,b]` | [GitHub Actions jobs on froog](https://froogcorp.com/docs/ci/): setup, stats and jobs (`--repo`, `--state`, `--limit`), one job, its log, public repositories. |
| `froog connections` · `add-email ADDR` · `primary ID` · `rm ID` | [Sign-in methods](https://froogcorp.com/docs/sign-in/) on your account. |
| `froog usage [--month YYYY-MM]` · `froog usage NAME [--since T] [--until T]` | This billing period: hours and cost per size, snapshots, the total, what your plan covers and what's past it. `--month`: a past calendar month at list prices. With NAME: that sandbox's hours and cost, this period or between `--since` and `--until` (`YYYY-MM-DD`, `YYYY-MM-DDTHH:MM` in UTC, unix seconds, or a time ago like `7d`). |
| `froog limits` | Sandboxes at once (from your plan), sizes, the free trial's days and hours used, and start limits. |
| `froog billing` | Your plan, its renewal, this period's usage against what's included, the wallet and the rates. With no plan, it lists the plans. |
| `froog billing plan 10\|20\|50\|100` | Choose a plan. The first opens Stripe Checkout in your browser and waits. Later changes apply at once. |
| `froog billing cancel [--keep-disks]` · `end-trial` · `portal` | End the plan at the end of the period (`--keep-disks`: [pause](https://froogcorp.com/docs/pricing/#pause), keeping disks 90 days) · end the free trial now and start paying · change the card or download invoices. |
| `froog billing extra-starts on\|off` | Pay from the wallet for [starts past the start limits](https://froogcorp.com/docs/pricing/#extra-starts) instead of being refused. |
| `froog wallet` · `froog wallet pay N` · `froog wallet quote N` · `froog wallet auto N\|off` | The wallet balance and auto top-up · add N dollars ($15 minimum, with the bonus from $200) · what N dollars adds with the bonus · top up N dollars when it runs low, or stop. |
| `froog onboard [--yes]` | A guided first run: sign in, SSH key, agent keys, a first sandbox (a plan is asked for only then), a first prompt. Skips what's done. |
| `froog dashboard` | Open the dashboard signed in, with a link that works once, for 15 minutes: the page asks "Continue as EMAIL?" first. Needs a full-access key. |
| `froog org` · `list` · `create NAME` · `switch ORG\|personal` | [Organizations](https://froogcorp.com/docs/organizations/): the workspace in scope · yours, the active one marked · a new one (you own it) · the one commands act in from now on. `--org ORG` (or `FROOG_ORG`) on any command acts in another for that command only. |
| `froog org members` · `invite EMAIL [--admin]` · `revoke-invite ID` · `accept TOKEN` · `remove EMAIL` · `leave` | Members and pending invites · [invite](https://froogcorp.com/docs/organizations/#org-invites) by email · revoke an invite · join with an invite link · remove a member · leave. |
| `froog org role EMAIL admin\|member` · `cap EMAIL [--running N] [--usage USD]` · `transfer EMAIL` | Roles (owner) · [per-member caps](https://froogcorp.com/docs/organizations/#org-seats) (0 removes one) · make another member the owner. |
| `froog org seats [N]` · `default-visibility [shared\|private]` · `rename NAME` · `delete [--yes]` | Seats bought · whether new sandboxes start shared or private · rename · delete (the last member, with no sandboxes). |
| `froog share NAME` · `froog unshare NAME` | Let every member use one of your organization sandboxes · make it [private](https://froogcorp.com/docs/organizations/#org-sandboxes) again. |
| `froog account` · `froog account close [--yes]` · `froog account reopen` | Your account · [close it](https://froogcorp.com/docs/data/#close-account) (asks you to type its email) · reopen it within 30 days. |
| `froog deletion status ID` | A deletion operation (`dop_…`). |

`froog ssh` and `froog exec` without a name use the last sandbox you created or started on this computer. Inside a sandbox, `self` means that sandbox (`froog host self 3000`, `froog snapshot self --name ready`), and `ssh`, `exec`, `stop` and `start` without a name use it. The API knows it too: with the in-sandbox key (in `~/.config/froog/config.json`), `/v1/sandboxes/self/…` is that sandbox, and so is `"name": "self"` in an [MCP](https://froogcorp.com/docs/mcp/) tool call, so curl, the SDKs (`froog.sandboxes.get("self")`) and agents inside a sandbox don't need its name. With any other key, `self` is `400`. No sandbox can be named `self`. `froog completions bash|zsh|fish` prints shell completions, `froog self-update` installs the newest CLI, and `froog logout` forgets the saved key.

Every command accepts `--json` for machine-readable output. The CLI reads `FROOG_API_KEY` and `FROOG_API_URL` from the environment, which overrides `~/.config/froog/config.json`.

## HTTP API

Base URL `https://api.froogcorp.com/v1`. Authenticate with an API key from the [dashboard](https://froogcorp.com/dashboard/) as a bearer token. Keys expire after 90 days by default (choose up to a year, or never, when you create one), and an expired key gets `401 api_key_expired`. Keys can be [scoped](https://froogcorp.com/docs/api-keys/) to actions and sandboxes. Requests and responses are JSON, and errors look like `{"error": {"code": "...", "message": "...", "request_id": "req_..."}}`. Every response has an `X-Request-Id` header with the same id. Quote it when you report a problem.

**Rate limit.** Each account can send 10,000 requests per 60 seconds, to any endpoint: all its API keys, in-sandbox keys and dashboard sessions together, from any address. Requests without a valid key count per IP address instead (each /64 over IPv6), 10,000 per 60 seconds. Sandboxes on one froog host share its IPv4 address, so their requests without a key aren't counted together, and over IPv6 each sandbox counts by its own address. A request counts once when it arrives, so a long-running one counts once. Every response carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (seconds until the window ends) and `RateLimit-Policy` (`10000;w=60`). Past the limit you get `429 account_rate_limited` (or `429 ip_rate_limited` without a key) with `Retry-After` in seconds.

```sh
$ curl -s https://api.froogcorp.com/v1/sandboxes \
    -H "Authorization: Bearer $FROOG_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"name": "ci-1", "size": "small"}'

$ curl -s https://api.froogcorp.com/v1/sandboxes/ci-1/exec \
    -H "Authorization: Bearer $FROOG_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"command": "uname -a", "timeout_secs": 60}'
{"exit_code":0,"stdout":"Linux froog-… 6.8.0 …\n","stderr":""}
```

Creating, starting, forking and restoring fail with `402 subscription_required` without a plan, `402 insufficient_funds` when this period's plan usage and the wallet are used up, and `429 trial_compute_limit_reached` past the free trial's 25 hours. During the trial, no auto-stop or a timer over 2 hours fails with `403 trial_auto_stop_required`, and an account whose card already had a trial elsewhere gets `402 trial_blocked`. `xlarge` below the $100 plan fails with `403 plan_required`, a paused plan with `402 plan_paused`, and a closed account with `403 account_closed`. In an [organization](https://froogcorp.com/docs/organizations/), a member past their [caps](https://froogcorp.com/docs/organizations/#org-seats) gets `429 member_limit_reached` or `402 team_member_cap_reached`. Past a start limit (per minute, hour or day, see `GET /v1/limits`) without [extra starts](https://froogcorp.com/docs/pricing/#extra-starts) they fail with `429 limit_reached` and the headers `Retry-After` (seconds until a start is free again), `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy` (e.g. `10;w=60`). Creating, stopping, starting, forking and restoring return `202 Accepted` and continue in the background. Poll `GET /v1/sandboxes/{name}` until `state` is `running` or `stopped`.

**Retries.** Send an `Idempotency-Key` header (any unique string up to 255 characters) with `POST /sandboxes` and `POST /sandboxes/{name}/fork` to make them safe to retry after a timeout or a dropped connection. A retry with the same key within 24 hours returns the first response, with the same sandboxes and status and an `Idempotent-Replayed: true` header, instead of creating more. The same key with a different request gets `422 idempotency_key_reused`, and a retry while the first request is still running gets `409 idempotency_key_in_use`. Errors aren't kept, so a retry after one runs again. Keys belong to the account. `froog new` and `froog fork` send one and retry with it when the connection drops.

**Streamed exec.** With `"stream": true` (or `Accept: application/x-ndjson`), `POST /sandboxes/{name}/exec` answers `application/x-ndjson`: one JSON object per line, as the output arrives. First `{"type":"started","command_id":…}`, then `{"type":"stdout","data":…}` and `{"type":"stderr","data":…}` chunks (UTF-8 text), then exactly one `{"type":"exit","exit_code":0,"timed_out":false}`, or `{"type":"error","code":…,"message":…}` if the connection to the sandbox broke. Errors before the command starts (not running, bad request) are ordinary JSON errors. Hanging up stops the command. It's in the history with mode `stream`. `froog exec` streams this way, and the SDKs have `execStream` (`exec_stream` in Python).

**Watching files and ports.** Two more NDJSON streams stay open while you listen (up to `timeout_secs`, 1-3600, default 3600, then reconnect). `GET /sandboxes/{name}/files/watch?path=src&recursive=true` sends `{"type":"ready","method":"inotify"}`, then `{"type":"change","event":"create"|"modify"|"delete","path":…,"dir":…}` for each change under the path (a file written and closed is `modify`, a move is a delete and a create), then `{"type":"end","reason":"timeout"}`. Paths come back as given: relative to `~` or absolute. It uses inotify where the image has `inotify-tools`, and otherwise scans every second (`"method":"poll"`), which sees changes up to a second late. A path that doesn't exist sends `{"type":"error"}`. `GET /sandboxes/{name}/ports/watch` sends `ready`, then `{"type":"open"|"close","port":3000,"protocol":"tcp"}` as TCP ports start or stop listening on any address (the ones already listening come first as `open`), read every second. Both need a running sandbox (or start an [auto-start](https://froogcorp.com/docs/sandboxes/#auto-start) one), `file.read` and `sandbox.read` respectively, and each holds one of the sandbox's 8 command slots while open. SDKs: `sb.files.watch(path)` yields the changes. The flat `watchFiles` and `watchPorts` yield every event.

```sh
$ curl -sN https://api.froogcorp.com/v1/sandboxes/ci-1/exec \
    -H "Authorization: Bearer $FROOG_API_KEY" -H "Content-Type: application/json" \
    -d '{"command": "make test", "stream": true}'
{"type":"started","command_id":"cmd_…"}
{"type":"stdout","data":"ok  build\n"}
{"type":"exit","exit_code":0,"timed_out":false}
```

```sh
$ KEY=$(uuidgen)   # reuse it for every retry of this request
$ curl -s --retry 3 https://api.froogcorp.com/v1/sandboxes \
    -H "Authorization: Bearer $FROOG_API_KEY" \
    -H "Idempotency-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{"name": "ci-2"}'
```

| GET | `/sandboxes?label=&name=&name_prefix=&state=&sort=&limit=&cursor=` | List sandboxes, oldest first (`sort=desc`: newest first). Filters: `label=KEY=VALUE` or `label=KEY` (repeat to require several), `name`, `name_prefix`, `state` (comma-separated for several). Without `limit` you get every match. With `limit` (1-200), an `X-Next-Cursor` header comes back while more follow: pass it as `cursor`. The cursor is `<created_at>_<id>` of the page's last sandbox. Unknown parameters get `400 invalid_query` |
| --- | --- | --- |
| POST | `/sandboxes` | Create: `{name?, size?, ttl_secs?, count?, setup?, environment?, os?, from?, snapshots?, monitoring?, env?, files?, labels?, max_lifetime_secs?, delete_after_secs?, expires_in_secs?, repo?, auto_start?}`. `ttl_secs` is the [auto-stop timer](https://froogcorp.com/docs/sandboxes/#auto-stop): seconds after each start (default 3600, `0` = none, at most 30 days). `auto_start: true` starts it on a URL request or an API call while stopped (see [auto-start](https://froogcorp.com/docs/sandboxes/#auto-start)). `max_lifetime_secs` stops it that long after each start and `delete_after_secs` deletes it after staying stopped that long (see [lifetime](https://froogcorp.com/docs/sandboxes/#lifetime)). `repo: {url, branch?, dir?}` is cloned into it at boot (see [one sandbox's repo](https://froogcorp.com/docs/environment/#sandbox-repo)). `labels` is a `{KEY: VALUE}` object (see [labels](https://froogcorp.com/docs/sandboxes/#labels)). `setup` is a script run once after the first boot, `environment: false` starts without your environment (the default from a share code or its copy, `true` opts in), `from` starts from a saved snapshot (or a [share code](https://froogcorp.com/docs/snapshots/#share) `fsh_…`), `environment_name` picks an [environment](https://froogcorp.com/docs/environment/#environments) (default: your default one), `files: [{path, content, encoding?}]` are written into `~` before it's `running` (`encoding: "base64"` for binary, up to 20, 1 MiB together) |
| GET | `/sandboxes/{name}` | Show one. Lists and this include `period_cost_usd` (its running time this billing period, at list prices), `setup_status` (`none`, `running`, `done`, `failed`) and `setup_error`, `max_lifetime_secs` and `stop_at`, `delete_after_secs` and `delete_at`, `expires_at`, `repo` and `auto_start` |
| GET | `/sandboxes/{name}/metrics` | Read now from a running sandbox: `{sampled_at, cpu_count, cpu_percent, memory_used_bytes, memory_total_bytes, disk_used_bytes, disk_total_bytes, network_rx_bytes, network_tx_bytes, io_pressure}` |
| GET | `/sandboxes/{name}/usage` | Its running time and cost: `{name, from, to, seconds, cost_usd, lines: [{size, seconds, cost_usd}]}`. `?since=&until=` in unix seconds (default: this billing period so far) |
| PATCH | `/sandboxes/{name}` | Rename, change the auto-stop timer (a running sandbox then stops that long from now), labels, lifetime, auto-delete or auto-start: `{name?, ttl_secs?, labels?, max_lifetime_secs?, delete_after_secs?, expires_in_secs?, auto_start?}` (`0` removes a lifetime or auto-delete). In `labels`, a value sets that label and `null` removes it. Labels you don't name stay |
| GET | `/saved-snapshots` | Your saved snapshots: `[{name, id, source, source_deleted, os, size, size_gb, status, created_at}]`. Start one with `POST /sandboxes {"from": NAME}`. `GET /limits` has `saved_snapshot_allowance: {included, used, extra, price_per_extra_day_usd}` |
| GET · DELETE | `/saved-snapshots/{name}` | One saved snapshot (poll `status` after saving, until `available`) · remove it (and revoke its share codes) |
| GET · POST | `/saved-snapshots/{name}/shares` | Its [share codes](https://froogcorp.com/docs/snapshots/#share): `[{id, name, created_at, revoked_at, uses, last_used_at}]` · make one: `{id, name, code, created_at}` (the code only here) |
| DELETE | `/saved-snapshots/{name}/shares/{id}` | Revoke a share code |
| GET | `/saved-snapshots/{name}/files` · `…/files/download` | [Inside a saved snapshot](https://froogcorp.com/docs/snapshots/#snapshot-files): `?path=P` (default `~`): a file's details or a folder's entries · a file's bytes or a folder as tar |
| GET | `/snapshots?sort=&limit=&cursor=` | Every snapshot of the account, newest first (`sort=asc`: oldest first), each with its `sandbox`. Paged like `/sandboxes`: with `limit` (1-200), `X-Next-Cursor` comes back while more follow |
| DELETE | `/sandboxes/{name}` | Delete it and its automatic snapshots (saved snapshots stay) |
| POST | `/sandboxes/{name}/exec` | Run a shell command: `{command, timeout_secs?, cwd?, env?, stream?, stdin?, stdin_b64?}` (max 3600) → `{exit_code, stdout, stderr, timed_out, command_id}`. `cwd` is relative to `~` or absolute, `env` a `{KEY: VALUE}` object for this command only. Past its timeout the command is killed: `timed_out: true`, exit code 124, and the output until then. `stream: true` (or `Accept: application/x-ndjson`): see [streamed exec](https://froogcorp.com/docs/api/#exec-stream). `stdin` (text) or `stdin_b64` is the command's input, up to 1 MiB (see [standard input](https://froogcorp.com/docs/jobs/#stdin)) |
| POST | `/sandboxes/{name}/wait` | Wait until something listens on a TCP port inside it: `{port, timeout_secs?}` (1-600, default 60) → `{port, ready, waited_ms}` (see [wait for a port](https://froogcorp.com/docs/urls/#wait-port)) |
| GET · POST | `/sandboxes/{name}/urls` | List · add (or change) a URL: `{port, public?, subdomain?, title?}` (up to 50 per sandbox), private (with `?_token=`) unless `public: true` |
| DELETE | `/sandboxes/{name}/urls/{port}` | Remove it |
| GET · POST | `/sandboxes/{name}/urls/{port}/tokens` | Live viewer tokens of a private URL · make one: `{ttl_secs?, label?}` → `{id, token, url, label, created_at, expires_at}` (see [a token per viewer](https://froogcorp.com/docs/urls/#url-tokens)) |
| DELETE | `/sandboxes/{name}/urls/{port}/tokens/{id}` | Revoke a viewer token |
| GET | `/sandboxes/{name}/files?path=P` | A file's details, or a folder and its entries. Relative paths are relative to `/home/user` |
| GET · PUT | `/sandboxes/{name}/files/content?path=P` | Download · upload raw bytes, up to 100 MiB (parent folders are created, and `&mode=755` sets permissions) |
| PUT | `/sandboxes/{name}/files/archive?path=DIR` | Unpack a `.tar.gz` (raw bytes, up to 100 MiB) into a folder, created if missing. `froog push` uses it |
| DELETE | `/sandboxes/{name}/files?path=P` | Delete a file or empty folder, or with `&recursive=true` a folder and its contents |
| POST | `/sandboxes/{name}/files/move` · `/files/mkdir` | `{from, to}` · `{path, mode?}` (parents are created, `mode` is octal, e.g. `"700"`, for the folder itself) |
| POST | `/sandboxes/{name}/files/permissions` | chmod: `{path, mode, recursive?}`, `mode` octal like `"755"`. The owner stays the sandbox user |
| POST | `/sandboxes/{name}/files/download-url` · `/files/upload-url` | `{path, expires_in?}` → `{url, method, expires_at}`: a link that GETs the file, or takes a `PUT` of its raw bytes, with no Authorization header (15 minutes by default, 60 s to 24 h, up to 100 MiB). Hand it to a browser, a CI step or another service |
| GET | `/sandboxes/{name}/files/search?pattern=*.py&path=~` | Paths whose name matches a glob: `{files, truncated, errors?}` (at most 10,000, absolute) |
| GET | `/sandboxes/{name}/files/find?pattern=TEXT&path=~&regex=false` | Every line containing the text (or an extended regex): `{matches: [{file, line, content}], truncated}`. At most 1,000, lines cut at 500 characters, binary files skipped, symlinks not followed |
| GET | `/sandboxes/{name}/files/watch?path=&recursive=&timeout_secs=` | Stream file changes under a path as NDJSON (see [watching](https://froogcorp.com/docs/api/#watch)) |
| GET | `/sandboxes/{name}/ports/watch?timeout_secs=` | Stream TCP ports starting or stopping to listen as NDJSON |
| POST | `/sandboxes/{name}/files/replace` | `{paths, pattern, new_value}`: replace every occurrence of a plain text, byte for byte, in up to 100 files (5 MiB each), with one `{file, success, replacements?, error?}` per file |
| GET · POST | `/sandboxes/{name}/jobs` | List · start a background command: `{command, cwd?, env?, stdin?, stdin_b64?, stdin_open?}` |
| GET | `/sandboxes/{name}/jobs/{id}?offset=N` | State, exit code and output from byte `offset`. Pass `next_offset` back to follow |
| POST | `/sandboxes/{name}/jobs/{id}/stop` | Stop it (TERM, then KILL) |
| POST | `/sandboxes/{name}/jobs/{id}/stdin` | Write to the input of a job started with `stdin_open`, and/or close it: `{data?, data_b64?, close?}` → 204 (see [writing to a running job](https://froogcorp.com/docs/jobs/#job-stdin)) |
| GET | `/sandboxes/{name}/command-history?mode=&sort=&limit=&cursor=` | Commands, newest first (`sort=asc`: oldest first). Pass `next_cursor` back as `cursor` for older ones |
| GET | `/sandboxes/{name}/command-history/{id}` | One record: text, mode, status, exit code, output sizes |
| GET | `/sandboxes/{name}/command-history/{id}/logs?stream=stdout\|stderr&offset=N&encoding=base64` | Stored output from `offset` (≤1 MiB): `{data, next_offset, size, done}` |
| GET | `/sandboxes/{name}/processes` | Processes, busiest first. A stopped sandbox answers with its last sample and `live: false` |
| GET | `/sandboxes/{name}/processes/samples` | The 12 busiest processes every 30 seconds, kept 7 days, newest first: `?since=&until=` (unix seconds), `limit` (1-1000, default 120) |
| GET | `/sandboxes/{name}/processes/records` | Processes seen in the 30-second checks, kept 72 hours: `?since=&until=&running=true&limit=` (1-1000, default 200) |
| GET | `/sandboxes/{name}/metrics/history` | CPU, memory and disk every 30 seconds, kept 7 days: `?since=&until=&step=` |
| GET · PATCH | `/account/alerts` | Which emails the account gets, and automatic rebuilds: `{emails: {KIND: bool}, auto_rebuild}` |
| POST | `/sandboxes/{name}/prompt` | Prompt an agent and wait: `{prompt, agent?, model?, effort?, fast?, new?, conversation_id?, attachments?, timeout_secs?}`, or `400 provider_not_configured` without a key or login |
| POST | `/sandboxes/{name}/prompts` | Queue a prompt (same body). `202` with the prompt. `GET` lists them (`?conversation=`) |
| GET | `/sandboxes/{name}/prompts/{id}` | Status, result, error. `/events`: its events (JSON, SSE or NDJSON) |
| GET | `/sandboxes/{name}/events` | Every conversation's events (`?conversation=`, `?cursor=`) |
| GET | `/sandboxes/{name}/conversations[/{id}]` | Conversations, or one with its turns. `DELETE` removes one |
| POST | `/sandboxes/{name}/steer`, `/interrupt` | `{message, conversation_id?}`. `{conversation_id?}` |
| GET | `/provider-models` | Harnesses, models, effort levels, fast mode (no key needed) |
| GET | `/subscriptions` | Claude and ChatGPT sign-ins. `POST /subscriptions/{provider}/login` starts one |
| POST | `/sandboxes/{name}/stop` | Run `~/.froog/pre-stop`, snapshot and stop: `{force?}` (`true` keeps the last good snapshot if this one fails). The answer's `stop_id` tracks it (stopping again while it's pending answers the same id) |
| GET | `/stops/{id}` | A stop: `{id, sandbox, status, requested_at, ended_at, error}`, `status` `pending`, `failing` (its snapshot failed: the sandbox keeps running, unbilled, and froog retries this stop, see [Stopping safely](https://froogcorp.com/docs/sandboxes/#stopping)), `completed` (stopped, or erased with snapshots off), `superseded` (a start cancelled a failing stop) or `failed` (the sandbox is in `error`, a start retries from its last snapshot). Auto-stops and billing stops get one too |
| POST | `/sandboxes/{name}/start` | Start from the latest snapshot: `{size?, ttl_secs?, environment?, env?, environment_name?}`. `environment_name` moves it to that environment's latest version. `ttl_secs` is a new auto-stop timer, counted from this start (0 = none), `environment: false` boots without your environment (`true` with it again), `env` adds to its own variables. All are kept for later starts. New variables or environment need a fresh boot, so such a start doesn't [resume warm](https://froogcorp.com/docs/snapshots/#warm) |
| POST | `/sandboxes/{name}/fork` | `{name, snapshot?, count?, size?, env?, ttl_secs?, snapshots?, environment?, environment_name?}` (`env` goes on top of the source's own variables, `ttl_secs` defaults to the source's, `snapshots: false` makes copies a stop erases, `environment: false` runs them without your environment, [your logins removed](https://froogcorp.com/docs/environment/#scrub), `environment_name` puts them on that environment's latest version instead of the source's) |
| POST | `/sandboxes/{name}/restore` | `{snapshot}` (id or label) |
| GET | `/sandboxes/{name}/snapshots?sort=&limit=&cursor=` | List snapshots, oldest first (`sort=desc`: newest first), paged like `/snapshots` |
| POST | `/sandboxes/{name}/snapshots` | Save one: `{label?}`. With a new label past your plan's included saved snapshots and an empty wallet: `402 credit_required` |
| DELETE | `/sandboxes/{name}/snapshots` | Delete every automatic snapshot: `{deleted, kept}`. Saved snapshots stay, and so does the one a stopped sandbox starts from (`kept`) |
| GET | `/sandboxes/{name}/snapshots/{snap}` | One snapshot by id or label. `latest` is the newest available one |
| DELETE | `/sandboxes/{name}/snapshots/{snap}` | Delete a snapshot |
| GET | `/sandboxes/{name}/snapshots/{snap}/files` · `…/files/download` | [Inside a snapshot](https://froogcorp.com/docs/snapshots/#snapshot-files) (the sandbox may be stopped): `?path=P` (default `~`): a file's details or a folder's entries · a file's bytes or a folder as tar (`X-Froog-Entry-Kind: file\|dir`) |
| POST | `/sandboxes/{name}/desktop` | A link to the desktop in froog's viewer: `{url, expires_at}` (open within 10 minutes). `?theme=dark\|light` |
| POST | `/sandboxes/{name}/browser` | A link to the browser view (only Chrome). Body `{"profile": "/abs/dir"}` (optional), and `?theme=dark\|light` picks the theme |
| GET | `/sandboxes/{name}/ssh` | SSH host, user, port and pinned host key |
| POST | `/sandboxes/{name}/touch` | Record activity (`last_active_at`). It doesn't move the auto-stop deadline: `PATCH ttl_secs` does |
| GET · POST | `/inboxes` | List · make an email inbox, $2 a month `{username?, name?, sandbox?}` ([Tokenmail](https://froogcorp.com/docs/inboxes/)) |
| GET · PATCH · DELETE | `/inboxes/{inbox}` | Show · rename or attach to a sandbox `{name?, sandbox?}` · delete (by address, the part before @, or id) |
| GET · POST | `/inboxes/billing` · `/inboxes/billing/checkout` | Seats, inboxes and the subscription · a Checkout `url` that buys inboxes `{quantity?}` |
| GET | `/inboxes/{inbox}/messages?since=&limit=` | Received messages, newest first, with `codes` and `links` |
| GET · DELETE | `/inboxes/{inbox}/messages/{id}` | One message with `text` and `html` · delete; `…/raw` as received |
| GET | `/inboxes/{inbox}/wait?since=&after=&sender=&subject=&timeout=` | Wait for the next matching message (up to 120 s): `{message}`, `null` on timeout |
| GET · POST | `/webhooks` | List · add `{url, events?, name?}` (returns the signing secret once) |
| GET · PATCH · DELETE | `/webhooks/{id}` | Show · change `{url?, events?, name?, enabled?}` (the secret stays, `""` removes the name) · remove |
| GET | `/webhooks/{id}/deliveries?limit=` | Delivery attempts of the last 30 days, newest first: `[{event_id, event, sandbox?, attempt, status, response_code?, error?, duration_ms, created_at, next_attempt_at?}]`. `limit` 1-200, default 50 |
| POST | `/webhooks/{id}/rotate` · `/test` | New secret · send a ping |
| GET · DELETE | `/account/connections` · `/account/connections/{id}` | [Sign-in connections](https://froogcorp.com/docs/sign-in/) · remove one (not the last: `409 last_connection`) |
| POST | `/account/connections/email` · `/email/verify` | Send a code to `{email}` (`202`) · add it with `{email, code}` |
| PUT | `/account/primary` | `{connection}`: its address gets the account's emails |
| GET · POST | `/github` · `/github/connect` | [GitHub connection](https://froogcorp.com/docs/github/) · a link that installs the app |
| GET · PUT · DELETE | `/github/repos` · `/github/token-repos` · `/github/installations/{id}` | Repositories the app sees · limit the sandbox token `{repos}` · unlink |
| POST | `/sandboxes/{name}/github-token` | A one-hour GitHub token for the sandbox `{owner?}`: `{token, expires_at, owner, repositories}` |
| GET · PUT | `/ci` · `/ci/settings` | [Pushbox](https://froogcorp.com/docs/ci/) overview `?repo=&state=&limit=` · public repositories `{public_repos, public_users}` |
| GET | `/ci/runs` · `/ci/runs/{run_id}` | Workflow runs, a page at a time `?workflow=&repo=&branch=&state=&q=&limit=&offset=` · one run and its jobs |
| GET | `/ci/jobs/{id}` · `/ci/jobs/{id}/logs` | One job · its log (text) |
| GET | `/env` | Your environment variables (names and masked previews) |
| PUT · DELETE | `/env/{name}` | Set `{value}` · remove a variable |
| GET · PATCH | `/environment` | The default environment: `{name, default, version, repos, setup, repo_setup, files, updated_at}` (file names and sizes) · change `{repos?, setup?, repo_setup?}` (`setup: ""` removes it, `repo_setup: {"app": "npm ci"}`, `""` removes one). A change makes a new [version](https://froogcorp.com/docs/environment/#versions) |
| PUT · DELETE | `/environment/files` | Add or replace `{path, content}` · remove `?path=P` |
| GET · POST | `/environments` | [Your environments](https://froogcorp.com/docs/environment/#environments): `[{name, default, version, repos, files, setup, created_at, updated_at, sandboxes: [{version, sandboxes}]}]` · create `{name, copy_from?, default?}` |
| GET · PATCH · DELETE | `/environments/{env}` | One environment, like `/environment` · change `{repos?, setup?, repo_setup?, name?, default?}` (a rename or a new default makes no version) · delete it (not the default) |
| PUT · DELETE | `/environments/{env}/files` | Add or replace `{path, content}` · remove `?path=P` |
| GET | `/environments/{env}/versions` | `[{version, created_at, change, sandboxes}]`, newest first |
| POST | `/environments/{env}/upgrade` | Move its sandboxes to the latest version: `{sandboxes?}` (names, default all) → `{environment, version, applied, at_next_start}` |
| GET · POST | `/ssh-keys` | List · add `{name, public_key}` |
| DELETE | `/ssh-keys/{id}` | Remove a key |
| GET | `/account` | Your account and limits |
| GET · POST | `/api-keys` | List · create `{label?, ttl_days?, never_expires?, preset? \| actions?, sandboxes?, labels?, environments?}`. `labels` limits it to sandboxes with all of them (see [label-limited keys](https://froogcorp.com/docs/api-keys/#label-keys)), `environments` (names) to sandboxes of those (see [environment-limited keys](https://froogcorp.com/docs/api-keys/#env-keys)). Keys expire after 90 days unless you choose otherwise (up to 365, or never). Without a scope the key has full access. The key is returned once |
| DELETE | `/api-keys/{id}` | Revoke a key |
| GET · POST | `/orgs` | Your personal account and [organizations](https://froogcorp.com/docs/organizations/) · create one `{name}` |
| PATCH | `/orgs/active` | The active organization `{org}` (id, name or `personal`) |
| GET · PATCH · DELETE | `/orgs/{org}` | One organization · `{name?, default_visibility?}` · delete it |
| GET · PATCH · DELETE | `/orgs/{org}/members` · `/members/{member}` | Members and invites · `{role?, max_running_sandboxes?, max_usage_usd?}` · remove one |
| POST · DELETE | `/orgs/{org}/invites` · `/invites/{id}` | Invite `{email, role?}` · revoke |
| POST · PUT | `/orgs/{org}/leave` · `/transfer` · `/seats` | Leave · `{member}` becomes the owner · `{seats}` |
| GET · POST | `/org-invites/{token}` · `/org-invites/accept` | What an invite is for · accept it `{token}` |
| POST | `/sandboxes/{name}/share` · `/unshare` | Shared with the organization · private to its creator |
| POST | `/api-keys/{id}/rotate` | A new secret for the key (returned once), with the same id, scope and expiry |
| GET | `/audit?action=&sandbox=&since=&until=&sort=&limit=&cursor=` | The [audit log](https://froogcorp.com/docs/audit/), newest first: `{events, next_cursor}` (needs `account.admin`) |
| GET | `/audit/export?format=csv\|ndjson` | Every matching entry of the 90 days as one file, oldest first (same filters) |
| GET · PATCH | `/account/data-retention` | `{zero_data_retention, snapshots_off, monitoring_off}`. Changing needs a dashboard session, and turning on zero data retention needs `confirmation: "delete stopped sandbox data"` (the answer adds `deletion_operation`) |
| POST | `/account/close` | [Close the account](https://froogcorp.com/docs/data/#close-account), with the header `X-Froog-Confirm-Close: EMAIL`: `202 {account, operation}`. A full-access key or a session |
| POST | `/account/reopen` | Reopen it within 30 days: `{account, operation}` |
| GET | `/deletion-operations/{id}` | `{id, kind, status, stage, created_at, updated_at, expected_by, error}` |
| POST | `/dashboard-link` | `{url, expires_at}`: opens the dashboard signed in, once, within 15 minutes, after a "Continue as EMAIL?" page (opening the link alone signs nobody in). A full-access key or a session |
| GET · PUT · DELETE | `/telemetry` | The OpenTelemetry endpoint: `PUT {endpoint, headers}` answers `{telemetry, test: {ok, status, message}}` |
| POST | `/feedback` | Send feedback: `{message}` |
| GET | `/usage` | This billing period: `{lines: [{size, seconds, cost_usd}], saved_snapshot_days, snapshot_cost_usd, total_usd, included_usd, billable_usd, from, to, free_trial_until}`. `billable_usd` is the part past the plan, paid from the wallet. `?month=YYYY-MM`: that calendar month at list prices |
| GET | `/billing` | `{plan, plans, rates, included_usd, used_usd, remaining_usd, wallet_usd, auto_topup_usd, auto_topup_failures, min_topup_usd, grace_until, wallet_bonus, extra_starts, keep_disks_until}`. `plan` is `{usd, status, period_start, period_end, trial_ends_at, cancel_at_period_end, max_sandboxes, saved_snapshots}`, and `status` is `trialing`, `active`, `past_due` (a renewal's payment is being retried; the plan still works), `unpaid` (the first payment failed; the plan doesn't work until it's paid)… |
| POST · DELETE | `/billing/plan` | Choose or change a plan `{plan}`, answers `{url?, billing}` with a Stripe Checkout `url` for the first plan · cancel at the end of the period (`?keep_disks=true`: [pause](https://froogcorp.com/docs/pricing/#pause)) |
| PUT | `/billing/extra-starts` | `{enabled}`: [extra starts](https://froogcorp.com/docs/pricing/#extra-starts) on or off |
| GET | `/billing/wallet/quote?amount_usd=N` | `{amount_usd, bonus_percent, bonus_usd, credited_usd}` |
| POST | `/billing/trial/end` | End the free trial now and start paying for the plan |
| POST | `/billing/wallet/pay` | Add `{amount_usd}` to the wallet, charged to the saved card |
| PUT | `/billing/wallet/auto-topup` | Auto top-up amount `{amount_usd}`, `0` turns it off |
| POST | `/billing/portal` | A Stripe page URL to change the card or download invoices |

## SDKs and OpenAPI

The whole API is described in [froogcorp.com/openapi.json](https://froogcorp.com/openapi.json) (OpenAPI 3.1): generate a client in any language from it. Three ready clients with no dependencies, generated from that document, each one file from froogcorp.com. TypeScript is also the npm package `froogdev`:

```sh
# Python 3.8+, standard library only: one file, imported as froogdev
$ curl -fsSL https://froogcorp.com/sdk/froog.py -o froogdev.py
# TypeScript: Node 18+, Deno, Bun or a browser (uses fetch)
$ npm install froogdev
# Go 1.21+, standard library only: put it in a package directory of your module (package froog)
$ mkdir -p froog && curl -fsSL https://froogcorp.com/sdk/froog.go -o froog/froog.go
```

The files are [froogcorp.com/sdk/froog.py](https://froogcorp.com/sdk/froog.py) (save it as `froogdev.py`), [froog.ts](https://froogcorp.com/sdk/froog.ts) (import it as `"./froog"`, or use the npm package) and [froog.go](https://froogcorp.com/sdk/froog.go). Download them again to update; the npm package's version is the API version.

A sandbox is an object you act on by method. Creating one waits until it's running. Leaving the `with` block (Python), the `await using` scope (TypeScript) or calling `Delete` (Go) deletes it, unless you create it with `keep=True`.

```sh
# Python
from froogdev import Froog
froog = Froog()                                   # FROOG_API_KEY from the environment
with froog.sandboxes.create(size="small", env={"TENANT_ID": "42"}) as sb:
    r = sb.exec("uname -a", cwd="/tmp", timeout=60)
    print(r.stdout, r.exit_code)
    sb.files.write("app.py", "print('hi')")
    print(sb.exec("python3 app.py", check=True).stdout)   # check=True raises CommandError on a non-zero exit
    for e in sb.exec_stream("make test"):              # output as it arrives
        print(e.get("data", ""), end="")

// TypeScript
import { Froog } from "froogdev";
const froog = new Froog({ apiKey: process.env.FROOG_API_KEY });
await using sb = await froog.sandboxes.create({ size: "small" });
const r = await sb.exec("uname -a", { cwd: "/tmp", timeout: 60 });
await sb.files.write("app.py", "print('hi')");
for await (const e of sb.execStream("make test")) if (e.type === "stdout") process.stdout.write(e.data);
// without `await using`: await froog.sandboxes.use({ size: "small" }, async (sb) => { ... })

// Go
c := froog.New("")                                // FROOG_API_KEY from the environment
sb, err := c.Sandboxes.Create(ctx, froog.CreateSandbox{Size: froog.Ptr(froog.SizeSmall)})
if err != nil { return err }
defer sb.Delete(context.Background())
r, err := sb.Exec(ctx, "uname -a", &froog.ExecOptions{Cwd: "/tmp", Timeout: time.Minute})
```

The sandbox object (`Sandbox` in Python, `SandboxHandle` in TypeScript and Go) has:

- `exec(cmd, cwd=, env=, stdin=, timeout=)` and `exec_stream(...)`. `stdin` takes text or bytes.

- `files.read`, `read_text`, `write`, `list`, `info`, `exists`, `remove`, `mkdir`, `move` and `watch` (changes under a path as they happen: `for e in sb.files.watch("src"): print(e["event"], e["path"])`).

- `start` and `stop`, which wait until they're done. `stop` follows the stop's id.

- `fork(name)`, which returns the new sandbox once it's running, and `delete`.

- `snapshot(label)`, `host(port)` (returns the URL), `metrics` and `refresh`.

- `wait_for_port(port, timeout)`, which calls `POST /wait` until something listens on the port (up to 600 seconds) and raises a 408 `timeout` error if nothing does.

Get a sandbox that already exists with `froog.sandboxes.get(name)`, or list them with `froog.sandboxes.list(label="team=a")`. Pass a list for several labels, all of which must match: `label=["team=a", "tier"]` (TypeScript `{label: ["team=a", "tier"]}`, Go `&froog.ListSandboxesParams{Label: []string{"team=a", "tier"}}`). A sandbox you got this way is never deleted at the end of a block. In Go, `c.Sandbox(name)` makes a handle without a request.

Background commands run as [jobs](https://froogcorp.com/docs/jobs/), so they survive the connection. `spawn` returns a handle:

```sh
server = sb.spawn("npm run dev", cwd="app", env={"PORT": "3000"})
sb.wait_for_port(3000)
print(sb.host(3000))
print(server.logs())                               # its output so far, stdout and stderr together
server.kill()

build = sb.spawn("make build")
for chunk in build.follow(): print(chunk, end="")  # output as it arrives, until it ends
print(build.wait())                                # the exit code
p = sb.process("job_…")                            # reconnect later, from another program too
sb.processes()                                     # every background command, newest first

repl = sb.spawn("python3 -i", stdin_open=True)    # TypeScript {stdinOpen: true}, Go &ExecOptions{StdinOpen: true}
repl.write("print(6 * 7)\n")                      # text or bytes
repl.close_stdin()                                 # end of file (TypeScript closeStdin, Go CloseStdin)
```

Python also has an asyncio client with the same methods as coroutines. It stays standard-library only by running each call on a thread pool:

```sh
from froogdev import AsyncFroog
froog = AsyncFroog()
async with await froog.sandboxes.create() as sb:
    results = await asyncio.gather(*(sb.exec(f"pytest -k {k}") for k in keys))
    async for e in sb.exec_stream("make"): ...
    await (await sb.spawn("make build")).wait()
print(await froog.list_sandboxes())                # every flat method too
```

Under the objects, each client has one method per operation, named after its `operationId`: `createSandboxes`, `exec`, `readCommandLogs`, `hostPort` and so on. Python uses snake_case, and Go capitalizes the first letter. There are also `waitUntil` and `execStream`. These flat methods take path parameters first, then the JSON body, then query parameters. `createSandboxes` and `forkSandbox` take an optional idempotency key last. In Go, they take required query parameters as arguments, and optional ones and the idempotency key in an `…Params` struct.

Errors raise `FroogError` (Go: return `*froog.Error`). It carries the HTTP status, the API's error `code`, the message and the request id: `requestId` in TypeScript, `request_id` in Python, `RequestID` in Go. The TypeScript and Go files include types for every request and response.

```sh
# the same calls without objects
froog.create_sandboxes({"name": "box"}, idempotency_key="job-42")
froog.wait_until("box", "running")
print(froog.exec("box", {"command": "uname -a"})["stdout"])
```

## Limits

- Sandboxes running at once come from your plan: 2, 5, 10, 25, 60 or 125 (2 during the free trial). Ask us to raise it.

- Sandbox names: 1–40 characters, lowercase letters, digits and dashes.

- Up to 100 sandboxes per `new` or `fork` call, and up to 20 SSH keys.

- 10,000 API requests per 60 seconds per IP address (per /64 over IPv6), and 32 labels per sandbox.

- Up to 10 webhook endpoints. Each event is tried up to 8 times.

- Tokenmail inboxes: up to 100 per account (ask for more), one per VM. Messages up to 10 MB, 50 recipients each, 100 an hour per inbox (more are deferred and the sender retries), kept 30 days.

- GitHub: the sandbox token lasts 1 hour and reaches up to 100 token repositories. CI jobs run up to 6 hours 15 minutes and are listed 30 days.

- Email codes: 5 per address and network (an IPv4 address or an IPv6 /64), 20 per network and 30 per address an hour. 5 tries each. Agent registrations: 10 per network an hour.

- `exec` output is capped at 1 MiB per stream, its stdin at 1 MiB, and the timeout is up to 1 hour. Up to 8 commands run at once per sandbox (run long ones in the background).

- A hard lifetime of up to 30 days, and auto-delete up to 365 days after a stop.

- Up to 50 public URLs per sandbox, 100 live viewer tokens per URL, and 20 new URL names a day per account (each new name gets its own certificate).

- Stored command output is kept 90 days, up to 512 MiB per account (the oldest goes first). Process samples are kept 7 days.

- The pre-stop hook gets 60 seconds.

- Abuse such as crypto mining, spam or network scanning gets accounts suspended. So does a payment that's disputed or reported as fraudulent, at once: API keys stop working and sandboxes are stopped, with their disks kept.

## Troubleshooting

Start with `froog info NAME` (state, error, auto-stop timer, disk retention) and `froog status --check` (sign-in and API reachability). API errors carry a `code`, listed here.

| Problem | What to do |
| --- | --- |
| `Permission denied (publickey)` from `froog ssh` | Your key isn't on the sandbox. `froog keys list` shows your account's keys, and `froog keys add ~/.ssh/id_ed25519.pub` adds one. Keys are written at each start, so then `froog stop NAME` and `froog start NAME`. |
| `Host key verification failed` from plain `ssh` or an editor | A hand-written `~/.ssh/config` entry points at an old address or port: the sandbox started again on a new one. Replace it with `froog ssh-config`, whose entries look the address up at each connection. See [VS Code and Cursor](https://froogcorp.com/docs/editor/). |
| `409 conflict`: the sandbox isn't running | `froog start NAME`. SSH, `exec`, files, public URLs and the desktop need a running sandbox. History and saved snapshots don't. |
| The sandbox is in `error` | `froog info NAME` says why. `froog restore NAME@latest` brings it back from its newest snapshot. A new sandbox that never ran has no snapshot: delete it and create it again. |
| A sandbox stopped by itself | Its auto-stop timer ran out (`froog info` shows when, it counts from the start, not from activity: `froog extend NAME --hours 8` for longer, `--no-auto-stop` for none), the 24-hour grace period after plan usage and the wallet ran out ended (or passed its debt cap), or the free trial's 25 hours were used up. A `sandbox.stopped` [webhook](https://froogcorp.com/docs/webhooks/) tells you when it happens. |
| `sandbox.degraded`, or programs ended without a stop | The machine under the sandbox restarted or its VM stopped, and froog started it again. The disk is intact, but processes ended. Run long-lived services from the [setup script](https://froogcorp.com/docs/environment/) so they come back. |
| `402 subscription_required` · `402 insufficient_funds` | Choose a plan (`froog billing plan 20`) · this period's plan usage and the wallet are used up: `froog wallet pay 15`, or `froog wallet auto 20` for auto top-up. |
| `429 trial_compute_limit_reached` | The free trial's 25 hours are used: `froog billing end-trial` starts the plan now. |
| `403 trial_auto_stop_required` | The free trial needs an auto-stop timer of 2 hours or less: `--ttl 2h` (or less), not `--no-auto-stop`. The paid plan allows up to 30 days, or none. |
| `402 credit_required` | You keep all the saved snapshots your plan includes and the wallet is empty: add money (`froog wallet pay 15`) or remove a saved snapshot (`froog snapshots --rm NAME`). |
| `403 trial_no_saved_snapshots` | The free trial can't save new saved snapshots. The ones you have stay. `froog billing end-trial` starts the plan now. |
| `403 plan_by_request` | Plans above $100 a month are by request: email [hello@froog.tech](mailto:hello@froog.tech). |
| `402 trial_blocked` | The card already had a free trial on another account, so this account's plan starts paid and its charge didn't go through: `froog billing end-trial` (or update the card with `froog billing portal`). |
| `404 org_not_found` · `403 org_role_forbidden` · `403 sandbox_private` | The request named an [organization](https://froogcorp.com/docs/organizations/) you aren't in (`froog org list`), your role can't do this (ask an owner or admin), or the sandbox is a teammate's private one. A sandbox you can't find may be in another workspace: `froog org` shows the one in scope. |
| `429 member_limit_reached` · `402 team_member_cap_reached` | Your organization capped you: stop one of your organization sandboxes, or ask an owner or admin to raise the cap (`froog org cap`). |
| `429 limit_reached` | The message names the limit: sandboxes at once (stop one, or ask for more), starts per minute, hour or day (wait), or froog being at capacity (try again in a few minutes). `froog limits` shows where you are. |
| `snapshot_data_expired` | The sandbox stayed stopped over 14 days and its disk was deleted. `froog restore NAME@SNAPSHOT` from a saved snapshot, if you have one, or delete it. |
| `fast_mode_not_supported` | That harness or model has no fast mode. `froog prompt --list-models` lists the ones that do. |
| `no_running_turn` | A steer with nothing running in the conversation. Send a prompt instead. |
| `provider_not_configured` (exit code 78) | The agent has no key or login in that sandbox. `froog env set ANTHROPIC_API_KEY` (or the harness's key, see [Coding agents](https://froogcorp.com/docs/agents/)) and start the sandbox again, or log in inside it. |
| A variable, secret file or repo is missing | Environment changes reach a sandbox at its next start, and `--no-environment` sandboxes (and their forks) get none. Variables are loaded by login shells, `froog exec`, background commands and prompts: elsewhere, run `set -a; . ~/.froog/env; set +a`. |
| The setup script or a clone failed | `froog jobs NAME` lists the setup job, and `froog logs NAME JOB` prints its output. Private repos need `GH_TOKEN` or a `.git-credentials` secret file. |
| A public URL doesn't answer | The service must listen on that port inside the sandbox (`localhost` is fine): check with `froog exec NAME -- 'ss -ltn'`. The first visit to a new URL takes a few seconds while its certificate is issued. A private URL in an iframe on another site can stay blank: use `--public`. |
| Can't connect to a private IP, or to port 25 | Both are blocked from sandboxes. See [Network](https://froogcorp.com/docs/network/). |
| A stop was refused (`stop_refused_at` in its info) | Its snapshot failed, usually because the disk is full: check with `froog exec NAME -- df -h /` and free space. froog retries the stop by itself. `froog stop NAME --force` stops it now with its last good snapshot. `froog start NAME` cancels the stop. See [Stopping safely](https://froogcorp.com/docs/sandboxes/#stopping). |
| A command exited with 137 | It was killed with SIGKILL. With `oom_killed: true` it ran out of memory: use less, or a larger size (`froog stop NAME && froog start NAME --size large`). |
| A sandbox isn't answering (`unresponsive_since` in its info) | Wait a few minutes: after a crash it restarts by itself. Then `froog stop NAME --force && froog start NAME` (you lose what was written after its last snapshot). If it never had a snapshot, don't stop it: email us. See [Not answering](https://froogcorp.com/docs/sandboxes/#unresponsive). |
| A sandbox is in `error` | Read why with `froog info NAME`. froog rebuilds one whose VM was lost from its last snapshot by itself. Otherwise `froog start NAME` retries from it. See [Errored sandboxes](https://froogcorp.com/docs/sandboxes/#rebuild). |
| An email says the disk is 85% or 95% full | Find what's big with `froog exec NAME -- 'sudo du -xh / 2>/dev/null \| sort -h \| tail -n 20'` and delete it, or move to a larger size. See [Disk space](https://froogcorp.com/docs/sandboxes/#disk). |
| A desktop link doesn't open | Links must be opened within 10 minutes and stop working when the sandbox stops: run `froog desktop NAME` again. |
| `500 internal` with `(ref …)` | Something failed on froog's side. Send the ref with `froog feedback "…"` or to [hello@froog.tech](mailto:hello@froog.tech). |

## FAQ

**Is a sandbox a container?** No. Each one is a KVM virtual machine with its own kernel, so Docker, systemd, `/dev/kvm` and kernel modules work as on any Linux machine. See [Security and isolation](https://froogcorp.com/docs/security/).

**Do I get root?** Yes. You log in as `user`, with passwordless `sudo`.

**How long can a sandbox run?** As long as you like: there's no session limit. Every sandbox has an auto-stop timer, 1 hour after each start by default, up to 30 days, or no auto-stop at all (`--ttl`, `--no-auto-stop`, `froog extend NAME --hours N`). It counts from the start, not from activity. A running agent prompt holds it off. During the free trial it's 2 hours at most.

**Do programs keep running across a stop?** Only through a [warm resume](https://froogcorp.com/docs/snapshots/#warm), a start within 10 minutes of the stop. Otherwise a start boots from the disk, which keeps every file.

**What does a stopped sandbox cost?** Nothing. Its disk is kept 14 days for free. Save a snapshot to keep it longer.

**Is my data backed up?** Snapshots are copied off the machine that runs the sandbox to separate storage, encrypted, usually within minutes of being taken, so a failed machine doesn't lose them. Anything written after the last snapshot isn't copied yet, and snapshots aren't a backup service: keep work you can't lose in git, or download it.

**Does a sandbox have its own IP address?** Its own public IPv6 address, yes. SSH answers on it, and any port you open with `froog port open`. Over IPv4 it shares the address of the machine it runs on with other sandboxes: SSH reaches it on its own port of that address, and each port you open gets its own public port there too (`froog port list NAME` shows `27015/udp at 203.0.113.7:40002`). Those numbers differ from yours and can change when the sandbox starts on another machine. See [Open ports](https://froogcorp.com/docs/network/#ports).

**Can a sandbox send email?** Not over port 25, which is blocked. Use your email provider's API or its submission port (587 or 465).

**Can I run a web server or a webhook receiver?** Yes: `froog host NAME PORT` gives the port an HTTPS URL, websockets included. For a raw TCP or UDP port (a game server, say), `froog port open NAME 27015/udp` opens it on the sandbox's IPv6 address and on a public port of the machine's IPv4 address (`froog port list NAME` shows which).

**Are there GPUs?** No.

**Can I change a sandbox's size?** Yes, while it's stopped: `froog start NAME --size large`.

**Can I use my Claude or ChatGPT subscription instead of an API key?** Yes. Log in inside the sandbox once (`froog ssh NAME`, then `claude` or `codex`). The login stays on its disk, so forks and restarts keep it.

**How do I move files in and out?** `froog scp`, `git` inside the sandbox, the [file API](https://froogcorp.com/docs/api/), or signed download and upload URLs.

**Can froog see what's in my sandbox?** froog's control plane holds a key to each sandbox, which it uses to run your `exec` commands, files, setup and public URLs, and froog's operators run the machines. Commands run through froog are kept in your [command history](https://froogcorp.com/docs/history/) unless monitoring is off. See [Security and isolation](https://froogcorp.com/docs/security/).

**Which operating systems?** Ubuntu 24.04 (the default) or 26.04, Debian 13, and Omarchy (Arch Linux). No Windows or macOS sandboxes, though the CLI runs on Windows, macOS and Linux.
