Get started
Overview
Bowse runs two kinds of work on one fleet of KVM microVMs. A CI runner borrows a fresh machine for a single GitHub Actions job and hands it back. An agent machine is one you keep: it has a name, a disk that survives stops, a shell, a browser, and a desktop.
| CI runner | Agent machine | |
|---|---|---|
| Started by | A job with runs-on: bowse | SDK, CLI, console, or MCP |
| Lifetime | One job, then destroyed | Until you delete it |
| Disk | Wiped; use caches and artifacts | Kept across stop and start |
| Metered | Per minute | Per hour, prorated |
| Access | Logs in GitHub | Shell, CDP browser, desktop, public URLs |
Get started
Machine quickstart
Boot a machine, run a command in it, and park it — about five minutes.
- 1
Install
The Python package ships the SDK and the
bowseCLI. TypeScript projects add@bowse/sdk.pip install bowse # installs the Python SDK and the bowse CLInpm i @bowse/sdkpipx install bowse bowse --version - 2
Sign in
Use
bowse loginon your laptop, or an API key on servers and in CI. See Authentication.# Option 1 — interactive: opens a browser, saves a session for the CLI and SDKs bowse login # Option 2 — API key from Console → Settings → API keys export BOWSE_API_KEY="bws_live_…" - 3
Create a machine and run something
run()waits for the command and returns its exit code and output. The disk keeps the clone for next time.from bowse import Machine ada = Machine.create(name="ada", size="2x8", template="coding-agent") result = ada.run("git clone https://github.com/northwind/web && cd web && npm ci && npm test", timeout=600) print(result.exit_code) print(result.stdout[-500:]) ada.stop() # compute stops; the disk stays # later: Machine.get("ada").start()import { Machine } from "@bowse/sdk"; const ada = await Machine.create({ name: "ada", size: "2x8", template: "coding-agent" }); const result = await ada.run("git clone https://github.com/northwind/web && cd web && npm ci && npm test", { timeout: 600 }); console.log(result.exitCode); console.log(result.stdout.slice(-500)); await ada.stop(); // compute stops; the disk stays // later: await (await Machine.get("ada")).start();bowse machine create ada --size 2x8 --template coding-agent bowse machine exec ada -- git --version bowse machine ssh ada # interactive shell, no SSH keys bowse machine stop ada bowse machine start ada bowse machine delete ada # removes the machine and its disk - 4
Stop it, or delete it
A stopped machine isn’t charged for compute and keeps its files and installed packages.
delete()removes the machine and its disk.
Get started
Authentication
The SDKs and CLI look for credentials in this order:
- An
api_key/apiKeypassed to the client. - The
BOWSE_API_KEYenvironment variable. - The session saved by
bowse login.
Keys belong to one workspace and carry a role — viewer can list and read, member can create and run machines, admin can also manage keys, billing, and the GitHub installation. Rotate or revoke keys in Console → Settings → API keys; revocation takes effect on the next call.
Get started
Core concepts
- Workspace
- Owns machines, CI installations, caches, API keys, members, and the spend limit. Nothing is shared across workspaces.
- Machine
- A KVM microVM with its own kernel, root disk, and name. CI creates one per job; agents keep theirs across sessions.
- Template
- The image a machine boots from: ubuntu, coding-agent, browser, or one you build.
- Size
- vCPU × GB RAM, from 1x2 to 16x64. Root disk is fixed per size.
- Volume
- Extra storage attached to a machine for datasets, models, or caches that should outlive it.
- Published port
- A port inside a machine exposed at an HTTPS URL under on.bowse.dev.
A machine moves through these states:
creating→running→stopping→stopped→starting→running·deleted
CI runners
CI quickstart
Bowse acts as a self-hosted runner pool for the repositories you pick. Jobs that ask for a Bowse label run on a clean microVM; every other job is untouched.
- 1
Install the GitHub App
In the console, open CI → Connect GitHub and choose Only select repositories. The app asks only for what runners need — the full list is on the security page.
- 2
Change one label
Edit
runs-onon the job you want to move. Pick a size to see the label change.name: teston: [push, pull_request]jobs: test:runs-on:runs-on: bowse timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22, cache: npm } - run: npm ci && npm testubuntu-latest2 vCPU · 8 GB · $0.0012/min - 3
Push
The job appears in the Actions tab as usual, on a runner named after its size. Logs, annotations, re-runs, and required checks all stay in GitHub.
CI runners
Labels and sizes
Labels read bowse-<vCPU>x<GB RAM>. Plain bowse means bowse-2x8. Put the label on the job that needs it, not the whole workflow.
| Size / label | vCPU | RAM | Root disk | Good for |
|---|---|---|---|---|
| 1x2bowse-1x2 | 1 | 2 GB | 16 GB | Lint, format checks, small scripts |
| 2x4bowse-2x4 | 2 | 4 GB | 32 GB | Unit tests, light builds |
| 2x8defaultbowse-2x8 | 2 | 8 GB | 48 GB | Typical app CI; what plain runs-on: bowse gets |
| 4x16bowse-4x16 | 4 | 16 GB | 96 GB | Docker builds, browser-driving agents |
| 8x32bowse-8x32 | 8 | 32 GB | 160 GB | Monorepos, parallel test shards |
| 16x64bowse-16x64 | 16 | 64 GB | 256 GB | Heavy compiles, large integration suites |
jobs:
lint:
runs-on: bowse-1x2 # small and cheap
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint
test:
runs-on: bowse # same as bowse-2x8
strategy:
matrix: { shard: [1, 2, 3, 4] }
steps:
- uses: actions/checkout@v4
- run: npm ci && npx vitest --shard=${{ matrix.shard }}/4
image:
runs-on: bowse-4x16 # Docker build gets more CPU
steps:
- uses: actions/checkout@v4
- uses: docker/build-push-action@v6Not sure which size? Run on the default for a week, then check peak CPU and memory per job in Console → CI. Most lint jobs fit on 1x2; Docker builds and big test shards benefit from 4x16 and up. Prices per size are on the pricing page.
CI runners
Runner image
Bowse ships its own lean image rather than GitHub’s hosted one. If a step assumes a tool that isn’t listed, install it in the step or use the matching setup-* action.
| OS | Ubuntu 24.04 LTS, x64, fresh for every job |
| Runner | GitHub Actions runner, registered just in time for one job |
| Source control | git, git-lfs, GitHub CLI |
| Containers | Docker Engine, Buildx, Compose plugin |
| Build tools | build-essential, make, cmake, pkg-config |
| Languages | Python 3, Node.js LTS — pin other versions with setup-* actions |
| Utilities | curl, wget, jq, unzip, zip, zstd, rsync, openssl |
CI runners
Caching
Machines start empty; caches don’t. Bowse answers the cache API your actions already call, so these work without edits:
actions/cacheandactions/cache/restore/save- The
cache:input onsetup-node,setup-python,setup-go, andsetup-java - BuildKit layers through
cache-from: type=gha/cache-to: type=gha,mode=max
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm # no change needed
- run: npm ci && npm test
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=maxScoping follows GitHub’s rules: a branch reads its own entries and those saved on the default branch. Caches are isolated per workspace and repository, and entries nobody restores for a while expire on their own.
CI runners
Docker and services
Every runner has Docker Engine with Buildx and Compose, and the job owns the whole daemon — no shared socket with other jobs. GitHub service containers work as written.
jobs:
integration:
runs-on: bowse-4x16
services:
postgres:
image: postgres:16
env: { POSTGRES_PASSWORD: postgres }
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-retries 10
steps:
- uses: actions/checkout@v4
- run: docker compose -f compose.test.yml up -d redis
- run: npm ci && npm run test:integration
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgresCI runners
Troubleshooting
When a runner can’t start, Bowse posts a Bowse setup check on the commit explaining why. Fix the cause and re-run the job from GitHub.
| Check says | Why | Fix |
|---|---|---|
| Repository not connected | The Bowse GitHub App isn’t installed on this repository. | Add the repository in the app’s GitHub settings, then re-run the job. |
| No workspace linked | The GitHub installation isn’t attached to a Bowse workspace. | Console → CI → Link installation. |
| Spend limit reached | The workspace hit its spend cap. | Raise the limit in Console → Billing, then re-run. |
| Unknown runner label | The job asked for a label that isn’t a Bowse size, such as bowse-3x12. | Use a label from the size table. |
| Concurrency limit | More jobs are queued than the workspace may run at once. | Nothing to fix — jobs start as others finish. Ask support to raise the limit. |
For the runner’s own setup log, run bowse ci logs <job-id>.
Agent machines
Run commands
Agents are root inside their own machine, so apt, pip, npm, and Docker behave as they would on a server you own. Use run() for a result, run_stream() when the agent should react to output as it happens.
ada = Machine.get("ada")
# One-shot: waits for the command, returns output and exit code
r = ada.run("make check", timeout=900, cwd="/home/agent/web", env={"CI": "1"})
if r.exit_code != 0:
print(r.stderr)
# Streaming: react to output while a long build runs
for line in ada.run_stream("pnpm build"):
print(line.stream, line.text)const ada = await Machine.get("ada");
const r = await ada.run("make check", { timeout: 900, cwd: "/home/agent/web", env: { CI: "1" } });
if (r.exitCode !== 0) console.error(r.stderr);
for await (const line of ada.runStream("pnpm build")) {
console.log(line.stream, line.text);
}Every command is recorded with its exit code in Console → machine → History.
Agent machines
Browser and desktop
Machines created from the browser template run Chromium on a desktop session. browser() returns a CDP endpoint for Playwright or Puppeteer; display() returns a live view of the screen, read-only unless you pass control=True.
from bowse import Machine
from playwright.sync_api import sync_playwright
ada = Machine.create(name="ada-web", template="browser")
cdp_url = ada.browser().url # Chromium inside the machine
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(cdp_url)
page = browser.contexts[0].pages[0]
page.goto("https://github.com/northwind/web/pull/42")
print(page.title())
view = ada.display(control=False) # read-only live view of the desktop
print(view.url) # open it, or embed it in your own UIAgent machines
Public URLs
Publish a port and Bowse gives it an HTTPS URL with a certificate. Use it to hand a reviewer a running dev server, a notebook, or an API the agent just built. Unpublish when you’re done; stopping the machine takes the URL offline.
$ bowse port publish ada 3000
✓ https://ada-3000.on.bowse.dev → :3000
$ bowse port list ada
PORT URL SINCE
3000 https://ada-3000.on.bowse.dev 2m
$ bowse port unpublish ada 3000Agent machines
Lifecycle and persistence
Survives a stop
Root disk, installed packages, the persistent home directory, attached volumes, and the browser profile.
Doesn’t
Running processes and memory. Bring services back up after start() — a startup script in the template is the easy way.
Find a machine again by name with Machine.get("ada"). Stopped machines keep their disk until you delete them.
Agent machines
Templates
Three base templates cover most agents: ubuntu (bare), coding-agent (Git, Python, Node.js, build tools), and browser (adds Chromium and a desktop). Build your own when every machine needs the same setup.
# Start from a base, bake in your toolchain, reuse it for every agent
bowse template create web \
--from coding-agent \
--run "corepack enable && pnpm i -g turbo" \
--run "git clone https://github.com/northwind/web /home/agent/web"
bowse machine create ada --template web --size 4x16Agent machines
Network policy
Create a machine with network="off" to block all outbound traffic. Exec, files, and the shell keep working because they use Bowse’s control channel, not the machine’s network.
# Code you didn't write: no outbound traffic at all
ada = Machine.create(name="sandbox", template="ubuntu", network="off")
ada.run("curl -sS https://example.com") # fails: network is off
ada.run("python3 untrusted.py") # exec still works over the control channelSDKs and tools
Python and TypeScript
Both SDKs expose the same calls. Python is synchronous with an AsyncMachine twin; TypeScript returns promises.
| Python | TypeScript | Does |
|---|---|---|
| Machine.create(name, size, template, network, volumes) | Machine.create({ … }) | Boot a new machine |
| Machine.get(name) | Machine.get(name) | Look up an existing machine |
| Machine.list() | Machine.list() | Every machine in the workspace |
| m.run(cmd, timeout, cwd, env) | m.run(cmd, { … }) | Run and wait; returns exit code, stdout, stderr |
| m.run_stream(cmd) | m.runStream(cmd) | Iterate output lines as they arrive |
| m.files.write(path, data) · m.files.read(path) | m.files.write · m.files.read | Move files in and out |
| m.ports.publish(port) | m.ports.publish(port) | Expose a port at an HTTPS URL |
| m.browser() · m.display() | m.browser() · m.display() | CDP endpoint · live desktop URL |
| m.stop() · m.start() · m.delete() | m.stop() · m.start() · m.delete() | Lifecycle |
SDKs and tools
CLI reference
Every command accepts --json for scripts and --workspace when you belong to more than one.
| bowse login | Sign in through the browser and save a session |
| bowse machine create <name> --size 2x8 --template coding-agent | Boot a machine |
| bowse machine list | List machines and their state |
| bowse machine exec <name> -- <cmd> | Run one command and print its output |
| bowse machine ssh <name> | Interactive shell over an authenticated HTTPS connection |
| bowse machine stop | start | delete <name> | Lifecycle |
| bowse port publish | list | unpublish <name> <port> | Manage public URLs |
| bowse template create <name> --from <base> --run <cmd> | Build a custom template |
| bowse ci jobs [--repo owner/name] | Recent CI jobs with size, duration, and result |
| bowse ci logs <job-id> | Runner setup log for one job |
SDKs and tools
MCP server
The hosted MCP server lets an assistant create machines, run commands, and publish ports on its own. It speaks streamable HTTP at https://bowse.dev/mcp and signs clients in with OAuth 2.1 and PKCE. A client only gets the scopes you approve — machines:read, machines:exec, machines:admin — in one workspace.
claude mcp add --transport http bowse https://bowse.dev/mcp{
"mcpServers": {
"bowse": { "url": "https://bowse.dev/mcp" }
}
}| Tool | What it does |
|---|---|
| machine_create | Boot a machine. Pass an idempotency_key so retries don’t create duplicates. |
| machine_list · machine_info | Find machines and check their state and size. |
| machine_exec | Run a shell command; returns stdout, stderr, and the exit code. |
| machine_start · machine_stop | Resume or park a machine. Stopping keeps the disk. |
| machine_delete | Remove a machine and its disk for good. |
| port_publish · port_list · port_unpublish | Manage the machine’s public URLs. |
A good default flow for an agent: create with an idempotency key → poll machine_info until running → exec → publish a port if there’s something to show → stop when the task ends.
SDKs and tools
Errors
Both SDKs raise typed errors with the same names. Each carries request_id — include it when you write to support.
| Error | HTTP | Meaning |
|---|---|---|
| AuthError | 401 | Missing or revoked key, or an expired CLI session. Run bowse login again. |
| ScopeError | 403 | The key or MCP client lacks the scope for this call, e.g. machines:exec. |
| NotFoundError | 404 | No machine, template, or port with that name in this workspace. |
| ConflictError | 409 | A machine with that name already exists, or it’s mid-transition. |
| LimitError | 402 / 429 | Spend or concurrency limit reached; e.limit says which. |
| RateLimitError | 429 | Too many API calls; wait e.retry_after seconds. |
| ServerError | 5xx | Something failed on our side. Safe to retry with the same idempotency key. |
from bowse import Machine
from bowse.errors import AuthError, NotFoundError, LimitError, RateLimitError
try:
ada = Machine.get("ada")
except NotFoundError:
ada = Machine.create(name="ada", size="2x8")
except LimitError as e:
print("workspace limit:", e.limit) # spend or concurrency
except RateLimitError as e:
time.sleep(e.retry_after)Account
Billing and limits
- CI is metered per minute from boot to cleanup. Queue time isn’t billed.
- Machines are metered per hour of runtime, prorated. Stopped machines aren’t charged for compute.
- Both draw from one workspace balance, with no seats, platform fee, or minimum.
- Spend limit: once reached, new machines don’t start, running machines stop with their disks kept, and CI jobs get a setup check.
- Concurrency: each workspace has a limit shown in the console; ask support to raise it.
Rates for every size and an estimator are on the pricing page.
Account
Security model
Every CI job and every agent machine is its own KVM microVM with its own guest kernel. CI runners register with a just-in-time token that accepts exactly one job, and the machine and its disk are destroyed afterwards. Keys, caches, machines, and the GitHub installation each belong to one workspace. The security page covers GitHub App permissions, credentials, and data lifecycle in detail.
Account
Use cases
Coding agents
Clone the repo, install dependencies, run the suite, and publish the dev server so a reviewer can click through the change. Keep the machine between tasks so the next run starts warm.
Browser agents
Use the browser template, connect Playwright over CDP, and let a person watch the desktop — or take the mouse — when the agent hits a login wall.
Long-running agents
Jobs that run for hours write progress to disk. Stop the machine overnight and start it again tomorrow without losing the working tree.
Stuck on something?
Email support@bowse.dev with the request ID from the error. Agents can read the plain-text index at /llms.txt.