# Browsers

> Give your agents real Chrome browsers through a Steel-compatible API. Connect with Playwright or Puppeteer, hold a session for later, keep logins in profiles, replay recordings, or run sessions on your own Steel, Browserbase, Kernel, Hyperbrowser, Anchor or Browser Use account.

Web page: https://kuiper.sh/docs/browsers/

## Browsers for your agents

Kuiper runs real Chrome browsers for your agents. Each session gets a browser of its own, isolated from every other, and the browser is destroyed when the session ends. Nothing carries over to the next session (cookies, storage, downloads, cache) unless you ask for it:

- [Hibernating](#hibernate-a-session) keeps one session's state for later.
- [Profiles](#profiles) keep a browser's logins and settings between sessions.
- [Recordings](#recordings) let you replay what the browser showed.

The API is [Steel](https://steel.dev)'s. Steel's SDKs work with their base URL changed, and Playwright or Puppeteer connect over CDP to the URL a session answers with. The browsers run on Kuiper's own machines, so you need no Steel account and no Steel key. If you already have a hosted browser provider, sessions can [run there with your own key](#your-own-provider) through the same API.

The API lives at `https://gateway.kuiper.sh/browsers` and takes a [gateway key](https://kuiper.sh/docs/gateway-keys.md) (`kgw_…`).

## From a unit on Kuiper

Point your variables at the built-in `browsers` resource. Each running copy of the unit gets its own key and its region's gateway, with nothing to create first:

```sh
kuiper env set -p proxima -u agent \
  'STEEL_BASE_URL=${{browsers.BASE_URL}}' \
  'STEEL_CONNECT_URL=${{browsers.CONNECT_URL}}' \
  'STEEL_API_KEY=${{browsers.API_KEY}}'
kuiper restart -p proxima -u agent
```

## From anywhere else

Make a key for the project and use it the same way, from your laptop, CI or another cloud:

```sh
kuiper keys create laptop -p proxima --services browsers
export STEEL_BASE_URL=https://gateway.kuiper.sh/browsers
export STEEL_CONNECT_URL=wss://gateway.kuiper.sh/browsers/connect
export STEEL_API_KEY=kgw_…
```

## With Steel's SDKs

```ts
import Steel from "steel-sdk";
import { chromium } from "playwright";

const steel = new Steel({
  steelAPIKey: process.env.STEEL_API_KEY,
  baseURL: process.env.STEEL_BASE_URL,
});
const session = await steel.sessions.create({
  timeout: 15 * 60 * 1000,          // ms; 5 minutes when absent
  dimensions: { width: 1280, height: 800 },
});
const browser = await chromium.connectOverCDP(session.websocketUrl);
const context = browser.contexts()[0];
const page = context.pages()[0] ?? (await context.newPage());
await page.goto("https://example.com");
// …
await browser.close();
await steel.sessions.release(session.id);
```

```python
from steel import Steel
client = Steel(steel_api_key=os.environ["STEEL_API_KEY"], base_url=os.environ["STEEL_BASE_URL"])
session = client.sessions.create()
```

Without an SDK it is plain HTTP: `POST $STEEL_BASE_URL/v1/sessions` with the key in the `steel-api-key` header, or as `Authorization: Bearer`.

## Connect over CDP

A session's `websocketUrl` is `wss://gateway.kuiper.sh/browsers/connect?sessionId=<id>&token=<t>`. The token is bound to that session and lapses with it, so you can hand the URL to a library as it is:

```ts
await chromium.connectOverCDP(session.websocketUrl);          // Playwright
await puppeteer.connect({ browserWSEndpoint: session.websocketUrl });
```

Steel's documented form works too. Use it to reconnect later from a process that only kept the session ID:

```ts
await chromium.connectOverCDP(`${process.env.STEEL_CONNECT_URL}?sessionId=${id}&apiKey=${process.env.STEEL_API_KEY}`);
```

A session belongs to the project, not to the process that made it. Any key of the project can find it (`GET /v1/sessions/<id>`), reconnect to it and release it, so a session outlives an agent's turn, a restart or a redeploy. Keys of other projects get a 404.

## Timeouts

- `timeout` (in milliseconds) is the longest a session lives: 5 minutes by default, 24 hours at most. Asking for more is a 400 that names the limit.
- `inactivityTimeout` releases the session after that long with no CDP or live-view traffic. Without it, only `timeout` ends a session you forget to release.
- An ended session says why in `releaseReason`: `user_requested`, `timeout`, `inactivity_timeout` or `hibernation_expired` (with `status: "released"`), or `browser_crashed` or `node_lost` when the browser or its machine went away (with `status: "failed"`).

## Live view

`debugUrl` (also `sessionViewerUrl`) is a live view of the browser. Anyone with the URL can watch but not touch, so you can embed it in an iframe to show a person what the agent is doing. `interactiveDebugUrl` also takes clicks and keys, which is how a person takes over for a login or a CAPTCHA. Both stop working when the session ends. Treat the interactive one like a password.

## Hibernate a session

A session you will need again, but not soon, can give its browser back without losing its place. An agent waiting on a person, or a job that picks up tomorrow, are typical cases.

```sh
curl -X POST -H "steel-api-key: $STEEL_API_KEY" $STEEL_BASE_URL/v1/sessions/$ID/hibernate
curl -X POST -H "steel-api-key: $STEEL_API_KEY" $STEEL_BASE_URL/v1/sessions/$ID/resume
```

- **Hibernating** saves the session's cookies, site storage and open tabs, then destroys its browser. The session answers `status: "hibernated"` with `hibernatedUntil`, and isn't billed while it sleeps. The state is kept encrypted, at most 10 MB, for 7 days. After that the session ends with `releaseReason: "hibernation_expired"`.
- **Resuming** gives the session a fresh browser with the **same session ID**, its state and its tabs. Connecting to a hibernated session resumes it too, so an agent that only kept the URL reconnects as if nothing happened. Pages load again, so whatever lived only in a page's memory, such as an unsent form, is gone.
- **`timeout` counts live time only.** A session with a 30-minute `timeout` that ran 20 minutes before hibernating has 10 minutes left when it resumes. One with no time left can't be resumed (409).
- **Automatically:** `"kuiper": {"onInactive": "hibernate"}` in the create request hibernates the session when its `inactivityTimeout` passes, instead of releasing it.
- **Releasing** a hibernated session deletes its state at once.

`GET /v1/sessions?status=hibernated` lists the project's hibernated sessions.

## Profiles

A profile is a browser's user data (cookies, storage, IndexedDB, saved logins and settings), kept between sessions the way Steel does it:

```ts
// Log in once, and keep what the browser has when the session ends.
const first = await steel.sessions.create({ persistProfile: true });
// … log in, then release it: the profile is saved after the release.
await steel.sessions.release(first.id);
// Later sessions start from it:
const next = await steel.sessions.create({ profileId: first.profileId });
// Or start from it and save it again when this one ends:
await steel.sessions.create({ profileId: first.profileId, persistProfile: true });
```

- A profile is `UPLOADING` until its session ends and it is saved, then `READY`, or `FAILED` with an `error`. A session starts only from a `READY` profile (409 otherwise).
- `GET /v1/profiles`, `GET /v1/profiles/<id>` and `DELETE /v1/profiles/<id>` manage them.
- Caches are left out, and a profile is at most 300 MB compressed. A bigger one isn't saved.
- Profiles belong to the project, are encrypted at rest, and are deleted after 30 days without a session starting from them.

## Proxies

- **Your own:** `proxyUrl: "http://user:pass@proxy.example:8000"` sends the browser's traffic through your proxy. It isn't billed.
- **Kuiper's:** `useProxy: true`, or `useProxy: {geolocation: {country: "DE"}}` for an exit in one country, routes the browser through Kuiper's proxy provider. It is available once Kuiper has turned it on for your organisation; until then the request is a 400 that says so. Its traffic is billed per GiB. The provider's credentials never reach your browser.

`proxyUrl` and `useProxy` together are a 400. A hibernated session resumes with the same proxy.

## Recordings

A session can keep a record of what its browser showed, to replay later: the pages, what changed on them, and the mouse moves, clicks, scrolls and typing that changed them. Ask for it when you create the session:

```sh
curl -X POST -H "steel-api-key: $STEEL_API_KEY" -H "content-type: application/json" \
  -d '{"kuiper": {"record": true}}' $STEEL_BASE_URL/v1/sessions
# … use the session, release or hibernate it, then:
curl -H "steel-api-key: $STEEL_API_KEY" $STEEL_BASE_URL/v1/sessions/$ID/events > session.json
```

- **What it is:** [rrweb](https://github.com/rrweb-io/rrweb) events, as one JSON array, the way Steel Cloud answers them. rrweb's player replays them as they are: `new rrwebPlayer({ target, props: { events } })`. Password fields are masked. It isn't a video, and network traffic isn't in it.
- **When:** a recording is saved when the session is released or hibernated. Until then `…/events` answers 409 (`code: "recording"`). A session that hibernates and resumes goes on recording, and its events come back in order.
- **Limits:** at most 100 MB of events for each live period; past that the rest of the period isn't kept. Recordings are deleted 7 days after they are saved. Only headless sessions (the default) are recorded.
- **In the dashboard:** the project's **Browsers** page marks which ended sessions have a recording, and owners and admins replay them there. `kuiper browsers recording <id> > session.json` saves one.
- Recordings are encrypted at rest and cost nothing.

## Your own provider

Kuiper's browsers need no setup and no other account. If your organisation already pays for a hosted browser provider, your sessions can run there instead, with your own key. Your agents don't change: they still call Kuiper's API with a gateway key and connect to the `websocketUrl` it answers with. The providers are [Steel](https://steel.dev), [Browserbase](https://www.browserbase.com), [Kernel](https://www.kernel.sh), [Hyperbrowser](https://www.hyperbrowser.ai), [Anchor Browser](https://anchorbrowser.io) and [Browser Use](https://browser-use.com).

### Add the provider's key

Owners and admins add it in the dashboard, under **Access**, then **Providers**, or with the CLI. `--key -` reads the key from standard input:

```sh
kuiper providers add browserbase --key -
kuiper providers add steel --key - --projects proxima
kuiper providers test browserbase
```

- The kinds are `steel`, `browserbase`, `kernel`, `hyperbrowser`, `anchor` and `browser-use`. The key's name defaults to its kind.
- A Browserbase key may name a Browserbase project: `--field project_id=…`. Without it, sessions go to the key's own project.
- A Browser Use key from its EU deployment needs `--field region=eu`.
- `test` makes one cheap call with the key (it lists your sessions) and says `ok`, `invalid` or `unreachable`.

The key is write-only, like every provider key. It never reaches your units or your agents.

### Choose it for a project, or for one session

A project's sessions run on Kuiper's browsers until you choose a key for it, on the project's **Browsers** page or with the CLI:

```sh
kuiper browsers provider browserbase -p proxima   # this project's sessions run on Browserbase
kuiper browsers provider -p proxima               # where they run now, and the keys you can choose
kuiper browsers provider kuiper -p proxima        # back to Kuiper's own
```

One session can choose for itself in Kuiper's own options:

```ts
await steel.sessions.create({ kuiper: { provider: "steel" } });   // this one on your Steel account
await steel.sessions.create({ kuiper: { provider: "kuiper" } });  // this one on Kuiper's browsers
```

The project must be allowed to use the key (`--projects` when you add it). A key that doesn't exist or isn't shared with the project is a 400 or a 403. If you remove the key a project uses, its sessions are refused (409 `provider_missing`) until you choose again. They never move to Kuiper's browsers, or onto Kuiper's bill, by themselves.

### What stays the same

- **The API and the connect URL are Kuiper's.** A session gets Kuiper's own ID and a `websocketUrl` on the gateway. Kuiper connects to the provider for you, so your provider key, and the provider's own connect URL, never reach your agent.
- **Any key of the project** can find, connect to and release the session, from any gateway, as with Kuiper's browsers.
- **`timeout` is Kuiper's.** Kuiper ends the session at the provider when it passes. Disconnecting doesn't end it, as with Steel: Kuiper keeps Browserbase sessions alive (`keepAlive`), reconnects to Hyperbrowser with `keepAlive=true`, and sets Kernel's and Anchor's idle timeouts so they don't end a session early.
- **Releasing** (`POST /v1/sessions/<id>/release`, the dashboard, `kuiper browsers release`) releases it at the provider.
- **The answer** says where the session runs: `"kuiper": {"provider": "browserbase", "providerKind": "browserbase", "providerSessionId": "…", "billedBy": "provider"}`.

### What differs

Each provider has its own settings. Kuiper maps Steel's options onto the provider's. An option the provider has no setting for is a 400 that names it, for example `userAgent isn't available with Browserbase keys`. Kuiper never drops an option silently.

| Option | Steel | Browserbase | Kernel | Hyperbrowser | Anchor | Browser Use |
| --- | --- | --- | --- | --- | --- | --- |
| `timeout` | yes | up to 6 hours | up to 72 hours | up to 12 hours | yes | up to 4 hours |
| `inactivityTimeout` | yes | no | no | no | no | no |
| `userAgent` | yes | no | no | no | no | no |
| `dimensions` | yes | yes | yes | yes | yes | 320 to 6144 by 320 to 3456 |
| `blockAds` | yes | yes | no | yes | yes | no |
| `headless` | yes | no | yes | no | yes | no |
| `region` | Steel's | Browserbase's | Kernel's | Hyperbrowser's | no | no (the key's region) |
| `solveCaptcha` | yes | yes | no | yes | yes | yes |
| `useProxy` | yes | yes | no | yes | yes | yes |
| `proxyUrl` | yes | http and https | no | not socks4 | not socks4 | http only |
| `extensionIds` | yes | one | yes | yes | yes | up to 3 |
| `deviceConfig`, `sessionContext`, `optimizeBandwidth` and Steel's other options | yes | no | no | no | no | no |

- **`region`** names the provider's own regions (`us-east-1` for Browserbase, `lax` for Steel). The gateway's own region asks for nothing.
- **`useProxy`** is the provider's own proxy, on your provider bill, and needs no setting on Kuiper's side.
- **Defaults are the provider's.** An option you leave out keeps the provider's default. For example, Browserbase and Browser Use solve CAPTCHAs, and Browser Use sends traffic through its US proxy, unless you send `solveCaptcha: false` or `useProxy: false`.
- **Plans.** Some providers keep an option for paid plans: Browserbase's `keepAlive` needs its Hobby plan or above, for example. When a provider refuses, Kuiper answers with the provider's own words: a 400 `provider_refused`, a 402, a 429 `provider_rate_limited`, or a 502 `provider_key_refused` when the provider refused the key.
- **Hyperbrowser** stops a session once every page in it is closed, even while a client is connected.

These features are Kuiper's own, so they are a 400 on a provider: **profiles** (`profileId`, `persistProfile`), **recordings** (`kuiper.record`), **hibernation** (`kuiper.onInactive: "hibernate"`, `/hibernate`, `/resume`), the **files API** and `/live-details`. `GET /v1/sessions/<id>/context` works on Steel, which has its own. Quick actions (`/v1/scrape`, `/v1/screenshot`, `/v1/pdf`) run on Steel when the project uses a Steel key or the request names one, and are a 400 on the other providers, which have none. Add `"kuiper": {"provider": "kuiper"}` to run one on Kuiper's browsers instead.

### Live view

`debugUrl` is the provider's own live view, when the provider gives one that holds no key of yours: a link for that one session, valid while it runs. Anyone with it can watch the browser, and with most providers take control of it, so treat it like a password. Steel's and Kernel's open read-only as `debugUrl` and take input as `interactiveDebugUrl`. Where a provider's link would carry your key, `debugUrl` is `null`.

### Cost and limits

Your provider bills these sessions; Kuiper charges nothing for them. Budgets don't count them, and neither does Kuiper's limit on concurrent sessions: your provider's own limits apply. Each key's request limit still does. The **Browsers** page and `kuiper browsers sessions` show each session's provider, and the page sums up this month's sessions on each provider.

## Every endpoint

| Endpoint | |
| --- | --- |
| `POST /v1/sessions` | Steel's options: `sessionId`, `timeout`, `inactivityTimeout`, `userAgent`, `dimensions`, `deviceConfig`, `blockAds`, `optimizeBandwidth`, `sessionContext`, `proxyUrl`, `useProxy`, `profileId`, `persistProfile`, `region`, `namespace` and `headless`. Kuiper adds `kuiper.onInactive`, `kuiper.record` and `kuiper.provider`. |
| `GET /v1/sessions?status=live\|released\|hibernated` | The project's sessions. Released ones page with `cursor`. |
| `GET /v1/sessions/<id>`, `/context`, `/live-details` | As in Steel. |
| `POST /v1/sessions/<id>/release`, `POST /v1/sessions/release` | Release one session, or all of the project's live ones. |
| `POST /v1/sessions/<id>/hibernate`, `/resume` | [Hibernate a session](#hibernate-a-session). |
| `GET /v1/profiles`, `GET` or `DELETE /v1/profiles/<id>` | [Profiles](#profiles). |
| `GET /v1/sessions/<id>/events` | A recorded session's events ([Recordings](#recordings)). |
| `/v1/sessions/<id>/files…` | Steel's files API, up to 100 MB a file. |
| `POST /v1/scrape`, `/v1/screenshot`, `/v1/pdf` | One action on a browser of its own, released straight after. |

`region` must be the gateway's own region. To use another region, use its gateway, `gateway.<region>.kuiper.sh`. On [your own provider](#your-own-provider), `region` names the provider's regions instead.

### Not available yet

Asking for one of these is a 400 whose message names the option, for example `solveCaptcha is not available on this Kuiper`. A client that retries without the named option gets a session.

- `solveCaptcha`, `credentials`, `extensions` and `isSelenium`.
- `/v1/credentials…` and `…/captchas…` answer 501 `not_supported`.

## Cost and limits

Sessions are billed by the second while a browser is yours, from the moment it is ready until it is released or hibernated, at the price per session-hour. Quick actions (`/v1/scrape`, `/v1/screenshot`, `/v1/pdf`) are billed per 1,000, and Kuiper's proxy per GiB. Hibernated sessions, saved profiles and recordings cost nothing. Everything shows on the **Usage** page with the rest of your spend, and each session's cost on the project's **Browsers** page (`kuiper browsers sessions`). See [Usage and billing](https://kuiper.sh/docs/usage.md).

- **Budgets** cap what the organisation, a project or a key spends on browsers in a month. A used-up `block` budget refuses new sessions with **402** `budget_exceeded`. See [Budgets and limits](https://kuiper.sh/docs/budgets.md).
- **Concurrent sessions:** an organisation holds at most 10 live sessions at once. Past that, creating or resuming one is a **429** `too_many_sessions` with `Retry-After`. Hibernated sessions don't count.
- **Requests:** each key may make 300 requests a minute to the browser API. Past that, **429** `rate_limited` with `Retry-After`. CDP and live-view traffic aren't counted.

Every refusal has Steel's shape, `{"error": …, "message": …}`, plus a `code`, so a client can tell a budget from a limit.

```sh
kuiper browsers sessions -p proxima --live
kuiper browsers release <id> -p proxima
```

Sessions on [your own provider](#your-own-provider) are billed by the provider and count against none of these but the request limit.
