Docs
froog gives every agent its own Linux virtual machine. Each sandbox is a full Ubuntu 24.04 or 26.04, Debian 13 or Omarchy (Arch Linux) VM with its own kernel and disk, reached over SSH and driven from the froog CLI or the HTTP API.
Install
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://froog.tech/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
froog is invite-only during the beta. Once you're invited, you sign in with Google.
# opens the dashboard to approve this computer; also registers ~/.ssh/id_ed25519.pub
$ froog login
# 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 new returns when the sandbox accepts SSH, about a minute 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) |
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, DHH's Arch Linux setup with the Hyprland tiling desktop, as its own ISO installs it, 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 with noVNC for froog desktop, 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.
$ 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 the timer is required, 2 hours at most (and by default).
Hard lifetime and auto-delete
For CI and evals, where a forgotten sandbox shouldn't run or linger:
$ 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
--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 (--ttl). The lifetime is a hard cap: a running prompt doesn't move it, and froog extend --hours doesn't either.
Auto-start
With auto-start on, a stopped sandbox starts when something needs it: a request to one of its public URLs, or an exec, file, background command, prompt or wait call over the API. Together with the auto-stop timer, a preview or an agent's sandbox stops on its own and costs nothing while nobody uses it.
$ 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. Any key that can make the call can cause the start, without sandbox.resume. Forks keep the setting. Each start is in the audit log as sandbox.auto_start, by the key or by public-url.
Metrics
$ 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 409 monitoring_off. For history over time, use the OpenTelemetry export.
Labels
Tag sandboxes with your own key-value labels, such as the user, team or job they belong to, and filter lists by them:
$ 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.
Running time is billed per second, only while a sandbox runs: small $0.0137 an hour, default $0.0274, large $0.0685. A full 730-hour month is $10, $20 and $50. 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 |
froog billing plan 20chooses a plan. The first one opens Stripe Checkout in your browser (froog never sees your card number) and waits until you're subscribed.- Plan changes apply at once. An upgrade charges the difference for the rest of the period, and that money comes back as usage too.
froog billing cancelends the plan at the end of the period. - Unused plan usage expires at the end of each billing period. It doesn't carry over.
- For more sandboxes at once, email hello@froog.tech.
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.
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 10 or 25 cost $0.056 per snapshot per day, from the wallet.
- Network traffic (no egress charges), public URLs, the desktop and
/dev/kvmare included. - Stripe charges the plan monthly. Wallet payments are separate charges.
froog billing portalchanges the card and downloads invoices. froog billingshows your plan, its renewal, this period's usage against what's included, the wallet and the rates.froog usagebreaks the period down by size, andfroog usage --month 2026-10shows a past calendar month at list prices.froog listand the dashboard show what each sandbox's running time cost this billing period, andfroog usage NAME --since 2026-11-01 --until 2026-11-08one sandbox's hours and cost in any window (also7d, 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.
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.
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.
$ 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 window, after which it carries on). A running background command doesn't hold off 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, which you can read while the sandbox is stopped.
One-off runs and pushing a folder
$ 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 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 sandbox has snapshots off and a 15-minute auto-stop that froog run pushes back every 5 minutes while it runs, so if froog run is interrupted, the sandbox stops and is erased on its own within 15 minutes. With --keep it stays until that auto-stop (froog extend NAME --hours 4 for longer). 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 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:
$ 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; it can't be written to later.
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.
$ 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
| 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. A stopped sandbox answers with the busiest 12 processes as it stopped (kept 7 days) and live: false. Only commands run through froog are recorded, not the output of SSH sessions or services.
Public URLs
Give a port inside a sandbox a public HTTPS address, for a preview, a webhook receiver or a demo:
$ 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), 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). The URL stays the same across stop, start and restore. While the sandbox is stopped, visitors see a short notice, or, with 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, 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:
$ 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.
$ 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
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.
$ 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. 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 (unless
--no-environment), not the source sandbox's settings. - Saving a running sandbox snapshots its disk now, in the background (
statusgoes fromcreatingtoavailable), and a stopped one saves its last snapshot. - The first 10 saved snapshots are included. Each one past that is $0.056 a day.
froog snapshotsandfroog limitsshow how many you keep.
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.
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.
Coding agents
froog prompt runs a coding agent inside the sandbox and streams its output to your terminal:
$ froog prompt my-box "make the failing tests pass"
$ froog prompt my-box "add a /health endpoint" --agent codex
$ froog prompt my-box "review the diff" --model opus --effort high
$ froog prompt my-box "explain src/" --agent pi --model anthropic/claude-sonnet-4-5 --effort medium
Seven 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.
--agent | Harness | Key or login | --model · --effort |
|---|---|---|---|
claude (default) | Claude Code | ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN or a login | yes · low … max |
codex | OpenAI Codex | OPENAI_API_KEY or a ChatGPT 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 … xhigh |
amp | Amp | AMP_API_KEY (an access token) | no · no |
cursor | Cursor CLI | CURSOR_API_KEY | yes · no |
When a harness has neither its key nor a login in the sandbox, the prompt stops before running with provider_not_configured and says how to set it up (exit code 78, and the API answers 400 with that error code). Give agents their API keys once, as environment variables, and every sandbox has them:
$ froog env set ANTHROPIC_API_KEY # prompts for the value; for Claude Code
$ froog env set OPENAI_API_KEY # for Codex
Or sign in to an agent with your subscription inside the sandbox once: froog ssh my-box, then run claude (or codex) and follow its login. The login stays on the sandbox's disk, so forks and restarts keep it.
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 Claude Code, Codex, Gemini CLI, OpenCode, 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_screen_size, list_windows |
| Point and type | click, double_click, right_click, move_cursor, drag, scroll, type_text, press_key (e.g. ctrl+l) |
| Apps and windows | launch_app, bring_to_front, close_window, browser_navigate (Chrome) |
| 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). froog writes the computer entry into each harness's config at every start and leaves your other MCP servers alone. Screenshots are large in a model's context: ask for one window when you can. There is no accessibility tree yet: the agent works from screenshots.
Customize the agents
Every harness reads its own config from the sandbox's home directory, and froog prompt runs from ~. froog adds only two things: the froog skill (what the machine is, how to run servers and long jobs) in each harness's skills folder, and a short system prompt for Claude Code and pi saying they run headless in a sandbox. 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 | through an extension | ~/.pi/agent/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, and register MCP servers in the setup script:
$ 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:
$ 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
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.
$ 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. 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.
Repos, secret files & setup
Besides variables, 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.
$ 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 'cd ~/app && npm ci' | 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, and runs the setup script from ~ in a login shell. 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: 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: 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 or setup script. Use it for code or people you don't trust with your credentials. Its forks get none either.
Secret file contents and the setup script are encrypted at rest, and file contents are never shown again. Changes reach running sandboxes on their next start. Up to 20 repos, 50 files (64 KiB each, 1 MiB in total), and a 64 KiB script. The dashboard edits all of it too.
Webhooks
Get an HTTPS POST when a sandbox becomes running, stops, fails or is deleted, instead of polling.
$ 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, or its VM stopped and is being started again, so processes may have ended) and sandbox.recovered. The body is {"id", "type", "created_at", "sandbox": {…}, "reason"?}, reason saying why for degraded and recovered, 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. Delivery is at least once and in no guaranteed order, so deduplicate on id.
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:
// 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 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.
$ 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 send no metrics. To read one sandbox's numbers now, without a collector, use froog metrics. Every webhook event 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, or:
$ 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 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.
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.
The wall. froog.tech/wall (or froog wall) shows every running sandbox's desktop at once, live, in a grid of view-only tiles, and adds new sandboxes as they start. Click a tile's name or Use to work in it on the wall (Esc goes back), or Open for its own tab. Opening the wall starts each sandbox's desktop if it isn't running yet, and a sandbox on an open wall still stops at its auto-stop deadline.
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:
<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.
$ 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) from inside the sandbox:
$ 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.
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. 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.
$ 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 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, 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 gets401 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 anfroog loginneeds 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 labels (
--label KEY=VALUE, repeat for several; APIlabels) 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 gets403 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 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.
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), forked, restored and deleted; snapshots saved and deleted; public URLs and their tokens; environment variables, secret files and the setup; webhooks; SSH keys; OpenTelemetry; the plan, the wallet and 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. It is kept 90 days. Reads and failed requests aren't logged, nor are command runs (see command history).
$ froog audit --limit 20
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. Pass next_cursor as cursor for older entries. 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. 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, and froog-server admin delete-account erases an account's data.
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 cicreates, starts, forks and deletes only what it created, and can't touch billing or other keys. Store it asFROOG_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-sandboxfroogkey), and give each one what it needs with its own variables: the user's API keys, a tenant id.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
202at once. The sandbox is usable when its state isrunning(pollGET /v1/sandboxes/{name}, or take thesandbox.runningwebhook). - Run the agent.
POST /v1/sandboxes/{name}/prompt {"prompt", "agent", "model"}runs one of seven harnesses to completion with the user's keys, and a missing key answers400 provider_not_configured. For long work, start it as a background command and follow its output from your UI. - Show the result.
POST /urls {"port": 3000}gives a private preview URL (it carries a token, so treat it like a password). Use"public": trueto 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. - Keep only what you must. Monitoring off 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
defaultone. 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 (PATCHa newttl_secs, orfroog 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
defaultruns 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 or 10, 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 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. froog.tech/llms.txt is a short index with the key facts, and froog.tech/llms-full.txt is this whole page as Markdown (about 20,000 tokens). For Claude Code there is also a skill:
$ mkdir -p ~/.claude/skills/froog && curl -fsSL https://froog.tech/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:
## 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://froog.tech/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 mcpruns the server on stdio, signed in like the CLI (froog login, orFROOG_API_KEYin its environment). - Hosted:
https://api.froog.tech/mcp(Streamable HTTP) with an API key in the headerAuthorization: Bearer froog_…. Nothing to install.
Claude Code
$ claude mcp add froog -- froog mcp
# or hosted:
$ claude mcp add --transport http froog https://api.froog.tech/mcp --header "Authorization: Bearer $FROOG_API_KEY"
Add --scope user to have it in every project.
Codex
$ codex mcp add froog -- froog mcp
Hosted, in ~/.codex/config.toml (the key is read from FROOG_API_KEY):
[mcp_servers.froog]
url = "https://api.froog.tech/mcp"
bearer_token_env_var = "FROOG_API_KEY"
Cursor: in ~/.cursor/mcp.json (or .cursor/mcp.json in a project), one of:
{ "mcpServers": { "froog": { "command": "froog", "args": ["mcp"] } } }
{ "mcpServers": { "froog": {
"url": "https://api.froog.tech/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):
{ "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.froog.tech/mcp with the Authorization header.
| 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. |
create_sandbox,fork_sandboxandstart_sandboxwait up to 120 seconds (wait: falsereturns at once).execandread_filereturn 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 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. - The hosted endpoint is stateless: each
POSTgets one JSON answer, with no session id and no event stream (GETanswers 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:
- OpenAI Agents SDK (Python)
- Claude Agent SDK (Python and TypeScript)
- Vercel AI SDK (TypeScript)
- LangChain and LangGraph (Python)
- Mastra (TypeScript)
All of them are listed at froog.tech/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:
$ 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 streams over SSH while your terminal stays connected. For a long task, run the agent as a background command instead, and read its output later, even after the sandbox has stopped (command history):
$ 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):
$ 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, 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. There is no froog ssh-config command yet, so add the sandbox to ~/.ssh/config by hand. First connect once with froog ssh, which pins the sandbox's host key in froog's own known-hosts file, and look up its address:
$ froog ssh my-box -- true
$ froog info my-box
address 203.0.113.7 (SSH port 20014)
Then add an entry with that address and port:
Host my-box
HostName 203.0.113.7
Port 20014
User user
UserKnownHostsFile ~/.config/froog/known_hosts
StrictHostKeyChecking yes
ServerAliveInterval 30
In VS Code or Cursor, run Remote-SSH: Connect to Host, pick my-box, and open /home/user/app (or any folder). Plain ssh my-box works the same way. SSH uses your own key, the one froog login registered (see froog keys list).
- The address can change at every start. A stopped sandbox can come back with a new SSH address or port (a warm resume keeps it). After
froog start, runfroog ssh my-box -- trueagain and updateHostNameandPort.froog info my-box --jsonhas them assandbox.ipv4andsandbox.ssh_port, for a script. - 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 8moves it to 8 hours from now, andfroog extend my-box --no-auto-stopremoves it. - froog's known-hosts file is
~/.config/froog/known_hosts($XDG_CONFIG_HOME/froogif 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, usefroog host.
Private registries
Give every sandbox your registry credentials with account variables and secret files. 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, 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), 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, and its own public IPv6 address, which answers SSH and the ports you open. 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. 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: only SSH (keys only, at the address and port froog info shows) 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) on the sandbox's IPv6 address, 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. Only clients with IPv6 can reach these ports; there is no IPv4 port forwarding. 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). |
| 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:
$ 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 (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/tunand/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.
- 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 --full-auto), because the VM is the boundary. Anything that runs in a sandbox, including code an agent downloads, can read everything in it: your variables (in ~/.froog/env), secret files, 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_keysat every start, so adding or removing a key takes effect at the next start. Each sandbox has its own SSH host key, made by froog and pinned by the CLI, sofroog sshrefuses a machine that isn't yours. - API keys: each has scopes 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 forexec, files, background commands, setup, public URLs and the desktop. 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 froog.tech. 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. |
| Process samples | 7 days. |
| 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 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 turn off history, snapshots, or everything after a stop.
Report a security problem to hello@froog.tech, and abuse coming from a sandbox to abuse@froog.tech.
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, Daytona, Fly.io Sprites and Modal.
CLI reference
froog login | Log in: shows a code and opens the 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), --auto-start (start on a URL request or an API call while stopped). |
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. --from SAVED, --size, -e KEY=VALUE, --keep (keep it until its 15-minute auto-stop). |
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 | List, read or follow, and stop background commands. |
froog wait NAME --port P | Wait 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, newest first. --limit N, --cursor C for older entries. |
froog info NAME | Everything about one sandbox: state, address, auto-stop timer, 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 of metrics and events. |
froog data-retention | Show the account's data controls. 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: create NAME --preset read-only|full-sandbox|ci|admin or --actions, --sandbox NAME, --label KEY=VALUE, --ttl 30d|never. |
froog history NAME [CMD_ID] [--stderr] [-f] · froog ps NAME | Past commands and their output (90 days, also while stopped) and processes, busiest first. |
froog metrics NAME | CPU, memory, disk and network of a running sandbox, read now. |
froog prompt NAME "TEXT" | Run a coding agent and stream its output. --agent claude|codex|gemini|opencode|pi|amp|cursor, --model, --effort. |
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. |
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. The sandbox still stops at its auto-stop deadline. |
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 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). |
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 | Snapshot and stop billing. --no-wait prints the stop's id, and froog stop --status ID shows it (pending, completed 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 (--environment with it again), -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: --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). --auto-start / --no-auto-start turn 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 on stdio with this CLI's login, for Claude Code, Codex, Cursor and Claude Desktop: claude mcp add froog -- froog mcp. |
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|setup|add-file|rm-file | Repos, secret files and a setup script for every new sandbox. Set the script with setup FILE (or - for stdin, --clear to remove). |
froog keys list|add|rm | Manage the SSH keys allowed into your sandboxes. |
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 | Choose a plan. The first opens Stripe Checkout in your browser and waits. Later changes apply at once. |
froog billing cancel · end-trial · portal | End the plan at the end of the period · end the free trial now and start paying · change the card or download invoices. |
froog wallet · froog wallet pay N · froog wallet auto N|off | The wallet balance and auto top-up · add N dollars ($15 minimum) · top up N dollars when it runs low, or stop. |
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. 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.froog.tech/v1. Authenticate with an API key from the 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 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 IP address can send 10,000 requests per 60 seconds, to any endpoint. 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 ip_rate_limited with Retry-After in seconds.
$ curl -s https://api.froog.tech/v1/sandboxes \
-H "Authorization: Bearer $FROOG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "ci-1", "size": "small"}'
$ curl -s https://api.froog.tech/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. Past a start limit (per minute, hour or day, see GET /v1/limits) 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).
$ curl -sN https://api.froog.tech/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}
$ KEY=$(uuidgen) # reuse it for every retry of this request
$ curl -s --retry 3 https://api.froog.tech/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=&limit=&cursor= | List sandboxes, oldest 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?, repo?, auto_start?}. ttl_secs is the auto-stop timer: 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). max_lifetime_secs stops it that long after each start and delete_after_secs deletes it after staying stopped that long (see lifetime); repo: {url, branch?, dir?} is cloned into it at boot (see one sandbox's repo). labels is a {KEY: VALUE} object (see labels). setup is a script run once after the first boot, environment: false starts without your environment, from starts from a saved snapshot, 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, 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?, 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 |
| GET | /snapshots | Every snapshot of the account, newest first, each with its sandbox |
| 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. stdin (text) or stdin_b64 is the command's input, up to 1 MiB (see standard input) |
| 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) |
| 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) |
| 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=~®ex=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 |
| 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?} |
| 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) |
| GET | /sandboxes/{name}/command-history?mode=&limit=&cursor= | Commands, newest 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 |
| POST | /sandboxes/{name}/prompt | Run an agent to completion: {prompt, agent?, model?, effort?, timeout_secs?}, or 400 provider_not_configured without a key or login |
| POST | /sandboxes/{name}/stop | Snapshot and stop. 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, completed (stopped, or erased with snapshots off) 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?}. 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 |
| POST | /sandboxes/{name}/fork | {name, snapshot?, count?, size?, env?, ttl_secs?, snapshots?} (env goes on top of the source's own variables; ttl_secs defaults to the source's; snapshots: false makes copies a stop erases) |
| POST | /sandboxes/{name}/restore | {snapshot} (id or label) |
| GET | /sandboxes/{name}/snapshots | List snapshots |
| POST | /sandboxes/{name}/snapshots | Save one: {label?} |
| 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 |
| 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 | /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 | /env | Your environment variables (names and masked previews) |
| PUT · DELETE | /env/{name} | Set {value} · remove a variable |
| GET · PATCH | /environment | Repos, setup script and secret files (names and sizes) · change {repos?, setup?} (setup: "" removes it) |
| PUT · DELETE | /environment/files | Add or replace {path, content} · remove ?path=P |
| 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?}. labels limits it to sandboxes with all of them (see label-limited 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 |
| POST | /api-keys/{id}/rotate | A new secret for the key (returned once), with the same id, scope and expiry |
| GET | /audit?limit=&cursor= | The audit log, newest first: {events, next_cursor} (needs account.admin) |
| 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" |
| 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}. 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… |
| 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 |
| 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 froog.tech/openapi.json (OpenAPI 3.1): generate a client in any language from it. Three ready clients, each one file with no dependencies, generated from that document:
# Python 3.8+, standard library only
$ curl -fsSLO https://froog.tech/sdk/froog.py
# TypeScript: Node 18+, Deno, Bun or a browser (uses fetch)
$ curl -fsSLO https://froog.tech/sdk/froog.ts
# Go 1.21+, standard library only: put it in a package directory of your module (package froog)
$ mkdir -p froog && curl -fsSL https://froog.tech/sdk/froog.go -o froog/froog.go
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.
# Python
from froog 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 "./froog";
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=)andexec_stream(...).stdintakes text or bytes.files.read,read_text,write,list,info,exists,remove,mkdirandmove.startandstop, which wait until they're done.stopfollows the stop's id.fork(name), which returns the new sandbox once it's running, anddelete.snapshot(label),host(port)(returns the URL),metricsandrefresh.wait_for_port(port, timeout), which runs a check inside the sandbox over exec until something listens on the port.
Get a sandbox that already exists with froog.sandboxes.get(name), or list them with froog.sandboxes.list(label="team=a"). 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, so they survive the connection. spawn returns a handle:
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
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:
from froog 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.
# 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 or 10 (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
neworforkcall, and up to 20 SSH keys. - 10,000 API requests per 60 seconds per IP address, and 32 labels per sandbox.
- Up to 10 webhook endpoints; each event is tried up to 8 times.
execoutput 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).
- Abuse such as crypto mining, spam or network scanning gets accounts suspended.
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 | The sandbox started again on a new address or port. Run froog ssh NAME -- true (it records the address in froog's known-hosts file) and update your ~/.ssh/config from froog info NAME. See VS Code and Cursor. |
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 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 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. |
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. |
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). |
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. |
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) 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. |
| 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. |
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.
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, 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, and SSH reaches it on its own port of that address. See Network.
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; only clients with IPv6 can reach it.
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, 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 unless monitoring is off. See Security and isolation.
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.