# Hold a browser from your app

> Bind your app's accounts to profiles, hold a profile's browser while your code works, renew the hold, let go without closing it and follow session events.

An app that works with many accounts — a poster, an inbox checker, a scraper — needs three things from Browlix: which profile belongs to which of its accounts, a browser it can use without someone else taking it mid-task, and a way to let go that does not throw away a warm browser the next job needs. Bindings and holds give you exactly that, through the same API, MCP tools, SDK and permissions as everything else.

A complete program that does all of this — and recovers from a lost connection and a restart — is the [sample app](https://browlix.com/en/docs/sample-app).

## Bind your accounts to profiles

A **binding** says "account `acc-42` of app `fb-poster` uses this profile". It never holds a password, cookie or token — the profile already carries the signed-in state.

```ts tab=TypeScript
const b = await bx.call("bindings.create", {
  body: { app_id: "fb-poster", external_account_id: "acc-42", profile_id: profileId, label: "Shop page" },
});
```

```bash tab=curl
curl -X POST https://browlix.com/api/public/v1/bindings \
  -H "Authorization: Bearer $BROWLIX_API_KEY" -H "Content-Type: application/json" \
  -d '{"command_id":"'$(uuidgen)'","app_id":"fb-poster","external_account_id":"acc-42","profile_id":"'$PROFILE_ID'"}'
```

- One binding per (`app_id`, `external_account_id`). A second one is `CONFLICT` with the existing `binding_id`; move it with `bindings.update` instead.
- `bindings.update` and `bindings.delete` need the `revision` you last read, so two tools never overwrite each other.
- `metadata` is up to 4 KiB of your own data. Keys that look like credentials (`password`, `token`, `cookie`, `secret`, `otp`, `auth`, `api_key`) are refused.
- `bindings.list` shows each binding with the profile's live session and who holds it.

Creating, changing and removing bindings needs `profile.edit`; reading them needs `profile.read`. Group scope applies: a key whose member sees only some groups sees only those bindings.

## Hold a browser

`sessions.acquire` opens the profile on a fitting machine — or reuses its browser if it is still open and free — and gives your app a **hold** on it.

```ts tab=TypeScript
const hold = await bx.acquireSession({
  appId: "fb-poster",
  externalAccountId: "acc-42",
  holderRef: "poster-worker-3",   // your stable name for this worker
  ttlSeconds: 120,                // renew within this time
  keepWarmSeconds: 300,           // stay open this long after you let go
  wait: true,                     // resolve once the browser is ready
  onLost: () => abortCurrentJob(), // someone newer holds it, or it ended
});
const a = await bx.attach(hold.sessionId);
// … drive the page with Playwright (see Attach with Playwright)
await hold.detach();
```

```bash tab=curl
curl -X POST https://browlix.com/api/public/v1/sessions/acquire \
  -H "Authorization: Bearer $BROWLIX_API_KEY" -H "Content-Type: application/json" \
  -d '{"command_id":"'$(uuidgen)'","app_id":"fb-poster","external_account_id":"acc-42","holder_ref":"poster-worker-3","ttl_seconds":120,"keep_warm_seconds":300}'
```

Send `profile_id` instead of `app_id` + `external_account_id` when you do not use bindings. Add `device_id` to choose the machine; otherwise Browlix picks the least busy online machine with the profile's OS.

The reply is the session plus `lease` (`generation`, `holder_ref`, `expires_at`, …) and `acquired`:

| `acquired` | HTTP | Meaning |
|---|---|---|
| `opened` | 201 | A new browser was started for you |
| `reused` | 200 | The browser was still open from an earlier hold; you got it with a new generation |
| `renewed` | 200 | You already held it (same key and `holder_ref`); the hold was extended |

### When someone else has it

A profile has one browser and one holder at a time. If it is in use, acquire answers `BUSY` with `details.holder_kind`:

| `holder_kind` | Who | `retryable` |
|---|---|---|
| `person` | Someone using it in the app (remote view with control) or a session opened without a hold | no — ask them |
| `automation` | A running automation flow | yes, when the run ends |
| `app` | Another app's current hold | yes, when it lets go or lapses |
| `stopping` | The browser is closing | yes, in a moment |
| `other_machine` | It is open on a machine other than the `device_id` you asked for | no |

Browlix never tells you who the other holder is. `RECOVERY_REQUIRED` means the last session ended uncertainly — [recover it](https://browlix.com/en/docs/sessions#recovering) first.

## Fencing

Every hold has a `generation`. Each time a different holder takes the browser the generation goes up, so a worker that was paused, lost its network or kept running after a timeout cannot act for the browser any more:

- `sessions.renew` and `sessions.detach` must send the `generation` and `holder_ref` acquire returned, with the same API key.
- An older generation, a hold that lapsed, was detached or whose session ended answers `FENCED` with `details.reason` (`superseded`, `expired`, `detached` or `session_ended`).
- On `FENCED`, stop touching that browser and acquire again. The SDK's `acquireSession` stops renewing and calls `onLost` for you.

Renew before `ttl_seconds` (30–600, default 120) runs out — the SDK renews every third of it. A hold that is not renewed **lapses**: the browser stays open for `keep_warm_seconds`, then stops unless someone acquires it.

## Let go without closing

`sessions.detach` ends your hold but never stops the browser by itself. It stays warm for `keep_warm_seconds` (0–1800, default 120, or the value you send with detach) so the next job for the same account starts instantly. After that it stops — unless a person took control or an automation run claimed it in the meantime; then it keeps running for them. To close it right away, send `keep_warm_seconds: 0` or call `sessions.stop`.

Browlix checks expired holds once a minute, so a browser may stay open up to about a minute past its keep-warm time.

## Follow session events

Webhooks can be missed while your receiver is down. `sessions.events` is the log behind them: every session state change and every hold acquired, detached, lapsed or ended, in id order.

```ts tab=TypeScript
let after = loadCursor(); // the last id you processed, 0 the first time
const r = await bx.call("sessions.events", { query: { after, limit: 200 } });
for (const e of r.data as { id: number; kind: string; session_id: string }[]) {
  handle(e);           // ids only grow; skip an id you have seen
  after = e.id;
}
saveCursor(after);
```

- Events are kept 7 days. `gap: true` means events after your cursor are already gone — resync with `sessions.list` and `bindings.list`.
- Filter with `session_id` or `profile_id`. Reading needs `session.read`.

## What each machine supports

`devices.capabilities` lists what every machine can do now — CDP attach (always same-machine), headless, profile sync, the storage key, remote view, automation, the synchronizer, the AI agent and the AdsPower-compatible API — with the worker version each needs, its capacity and how many browsers it runs. Check it instead of guessing from version numbers.

## Permissions

| Operation | Capability |
|---|---|
| `bindings.list` | `profile.read` |
| `bindings.create`, `bindings.update`, `bindings.delete` | `profile.edit` |
| `sessions.acquire`, `sessions.renew`, `sessions.detach` | `session.start` |
| `sessions.events` | `session.read` |
| `devices.capabilities` | `device.read` |

Every write is audited and takes a `command_id`, so a retry after a lost reply returns the first result ([commands and receipts](https://browlix.com/en/docs/commands-receipts)). Over [MCP](https://browlix.com/en/docs/mcp) the same operations are the tools `bindings_create`, `sessions_acquire`, `sessions_renew`, `sessions_detach`, `sessions_events` and so on.
