# OpenBrowse: The open-source Browser Use Cloud alternative > OpenBrowse is an open-source, self-hosted alternative to Browser Use Cloud: AI browser agents that run on your own hardware, driven through the v3 REST API, with schema-validated structured output, anti-hallucination grounding guards, and a live visual dashboard. Canonical URL: https://openbrowse.co Source repository: https://github.com/lujstn/openbrowse DOI: https://doi.org/10.5281/zenodo.21986248 Licence: MIT Author: Lucas Johnston Kurilov (https://orcid.org/0009-0004-3743-2349) --- # Benchmark Extract every currently-open vacancy from a careers page whose listings sit behind an embedded, cross-origin job board, returning the full output schema for each record. Conditions: Raspberry Pi 5, 16GB, Debian 64-bit, concurrency 1. Every run was given the identical task, schema and cost cap. Costs are LLM token spend only; OpenBrowse adds no per-task fee, Browser Use Cloud's own platform charge is not included in its figure. | Runtime | Model | Reasoning | Steps | Time | Tokens | LLM cost | Records | | --- | --- | --- | ---: | ---: | ---: | ---: | --- | | OpenBrowse | gpt-5.6-luna | max | 36 | 17m 03s | 1.08M | $0.22 | 14/14 | | OpenBrowse | gpt-5.6-terra | none | 11 | 1m 47s | 202k | $0.24 | 14/14 | | OpenBrowse | claude-sonnet-5 | high | 10 | 4m 02s | 242k | $0.40 | 14/14 | | OpenBrowse | gpt-5.6-sol | none | 8 | 2m 03s | 136k | $0.41 | 14/14 | | OpenBrowse | claude-sonnet-5 | none | 9 | 5m 18s | 237k | $0.51 | 14/14 | | OpenBrowse | gpt-5.6-terra | high | 17 | 5m 05s | 434k | $0.66 | 14/14 | | Browser Use Cloud | claude-sonnet-5 | high | 10 | 2m 36s | 859k | $0.78 | 14/14 (with hallucinated fields) | | OpenBrowse | gpt-5.6-sol | medium | 16 | 5m 24s | 339k | $1.12 | 14/14 | | OpenBrowse | claude-opus-5 | medium | 15 | 3m 56s | 398k | $1.32 | 14/14 | | OpenBrowse | claude-opus-5 | none | 17 | 4m 53s | 480k | $1.62 | 14/14 | Caveat on the Browser Use Cloud row: it recovered all 14 records, but some returned fields, job seniority among them, were hallucinated where the page never showed them. ## What we learned running it ### The saving is token efficiency, not a cheaper model Hold the model steady and the gap is still there. claude-sonnet-5 at reasoning high costs $0.78 on Browser Use Cloud and $0.40 here, because the run burns 242k tokens instead of 859k. What changes is how many tokens the runtime spends getting the agent to the answer. ### Reading a whole listing at once is where it goes Open 14 pages one at a time and you pay for 14 rounds of page context. read_pages resolves the same 14 in three waves of real tabs, so the agent reasons over the set instead of rebuilding context per page. ### We do not recommend our own cheapest run gpt-5.6-luna at reasoning max was the cheapest complete extraction at $0.22, and it took 17m 03s to get there. gpt-5.6-terra at none cost two cents more and finished in 1m 47s. That is why the cheapest row is not the recommended one. ### Every reasoning level got the same answer Every OpenBrowse run recovered 14 of 14 records at every reasoning level. What moved was time, steps and cost, sometimes threefold. Treat reasoningEffort as a cost and latency control and set it deliberately per model family. The exact task specification, including the prompt, output schema and cost cap, is published at https://github.com/lujstn/openbrowse/blob/main/benchmark.json. --- # OpenBrowse compared with Browser Use Cloud | | Browser Use Cloud | OpenBrowse | | --- | --- | --- | | Hosting | Managed, priced per task | Your hardware. You pay for LLM tokens and nothing else | | How it works | Code-first. The agent scripts its way through pages | Visual-first. The agent opens real tabs you can watch, like a person working | | Bulk page reads | One page at a time on the v3 agent we benchmarked | read_pages opens a whole listing in parallel tab waves, in one step | | Structured output | Schema-validated | Schema-validated, plus a live answer store with a completeness gate the agent has to pass before it can finish | | Anti-hallucination | Will fill fields the page never showed | On-screen data first, enriched only from the page's own structured data. Values without evidence are refused at the store boundary | | Profiles | Cloud profiles | Import your existing cloud profiles, cookies and localStorage, with one command | | Live view | A live URL you can embed from the moment the session starts, plus recordings afterwards | Real-time VNC of the actual browser, a step feed carrying the model's reasoning, and an IDE panel streaming the agent's sandbox scripts as they are written. No recordings | | Network egress | A managed US residential proxy, on by default, with a selectable country | Your machine's own IP address. There is no proxy layer. proxyCountryCode is accepted for SDK compatibility and does nothing | | Platform extras | Session recordings, screenshots, workspaces, and hosted integrations | None of them. enableRecording and skills are accepted and ignored, and the matching response fields come back empty | | API | v3 REST | The same v3 REST surface | | Operations | Someone else's problem | Yours. A box that has to stay up, and roughly 2GB of RAM per concurrent session | --- # Frequently asked questions ## What is OpenBrowse? A free, self-hosted alternative to Browser Use Cloud. Describe a job, an AI agent drives a real browser on hardware you own, and you get structured data back. ## How does it compare to Browser Use Cloud? Same v3 API, your hardware, your network, no per-task/platform/proxy fees. OpenBrowse does not currently support BU's v4 API though, nor does it have BU Cloud's recordings or workspaces features. ## What does it cost to run? Just LLM tokens: no platform fee, and maxCostUsd hard-caps any session. Our benchmark job cost $0.24 here against $0.78 on the cloud, though that pair changes model as well as runtime. Holding the model steady, claude-sonnet-5 at reasoning high cost $0.40 here against $0.78 there. ## How do I migrate from Browser Use Cloud? Change two lines: your apiKey and your baseUrl. Everything downstream carries over untouched. ## Can I bring my existing profiles? Yes. Import your cloud profile export and existing profileId references keep working. ## Which model should I use? Whichever suits the job. Worth knowing: OpenAI models do better with less reasoning, Anthropic's with more. ## Does it solve CAPTCHAs? Optionally, with your own CapSolver key. Most common types are solved; the rest are named rather than silently attempted. ## Does it work with the Browser Use v4 API? Not yet. OpenBrowse implements v3; v4 support is planned. ## Is OpenBrowse affiliated with Browser Use, and how is it licensed? No, it is independent, and built on Browser Use's own open-source SDK. MIT licensed, with a DOI so it can be cited. ## How is this different from Firecrawl? Firecrawl is a crawler: give it URLs, get clean page content back. OpenBrowse is an agent: give it an outcome and it works the site out for itself. ## How does it compare to Browserbase? Browserbase rents you a browser to drive. OpenBrowse is the driver, on your own hardware. No connect endpoint, so existing automation is a rewrite. --- # Documentation # Solving CAPTCHAs (/docs/captchas) A browser agent that cannot get past a challenge page cannot finish the task behind it. OpenBrowse detects CAPTCHAs itself and solves the ones a solving service can actually solve, through [CapSolver](https://capsolver.com/). Set `CAPSOLVER_API_KEY` to turn it on. Without it the `solve_captcha` tool is not registered at all, so an agent on a challenge page fails honestly rather than pretending. ## Detection is not the agent's job [#detection-is-not-the-agents-job] The agent never has to name the challenge it is looking at, because it is a poor judge of one: a model asked "what CAPTCHA is this?" will guess from a screenshot, and guess wrong on anything that does not carry a visible logo. Instead one page probe harvests the structural facts, the site key, the widget's own configuration, the challenge parameters the page's JavaScript received, and a registry of strategies classifies from that snapshot. Detection is authoritative. The agent calls `solve_captcha` and is told what was found. The consequence worth knowing is that a solve missing a runtime parameter is refused **before** a task is created, so nothing is spent, and the error names the field that was absent rather than failing opaquely after you have paid. ## What is solved and what is only named [#what-is-solved-and-what-is-only-named] Coverage follows CapSolver's published service list, and a test refuses any task type that list does not offer, so this table cannot quietly drift from what the service accepts. | Challenge | Status | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | reCAPTCHA v2, and v2 Enterprise | Solved | | reCAPTCHA v3, and v3 Enterprise | Solved | | Cloudflare Turnstile | Solved | | GeeTest v3 and v4 | Solved | | MTCaptcha | Solved | | AWS WAF token | Solved | | Image-to-text | Solved | | hCaptcha | Recognised and named. CapSolver offers no hCaptcha task, so nothing is attempted and nothing is charged | | DataDome | Recognised and named, for the same reason | | reCAPTCHA v2 image grid | Recognised. The ordinary token solve already clears the grid, so the click-the-grid path is unproven and not offered | | AWS WAF image grid | Recognised. The image path has never been proven against a live challenge, so it is not offered | A challenge in the lower half of that table is a real answer, not a failure to detect. The agent is told what the page is and can route around it. ## The solved checkbox does not tick [#the-solved-checkbox-does-not-tick] A solved challenge is written straight into the page, so the widget's checkbox does not visibly tick and the live view still shows an unsolved-looking CAPTCHA. This is normal. Success is judged only by the page moving on, never by how the widget looks. If you are watching a run in the [live view](/docs/live-view) this is the single most confusing moment in it. The token is injected where the page expects it, the form submits, and the widget's own rendering never catches up. ## What it costs [#what-it-costs] Every solve is priced from the cost field CapSolver returns for that task, not a flat guess, and folded into the session's `totalCostUsd` alongside the LLM spend. Failed solves are billed by the service too, so they are recorded as well; a solve that cost money and did not work still shows up in the total. Two bounds keep that from running away: * **`CAPTCHA_MAX_COST_USD`**, default `$0.03`, caps what a single **run** may spend on solving in total. It is a per-run ceiling, not a per-solve one: once a run's solves have reached it, further solves are refused with an error telling the agent to find another path. * **Two failures end it for that host.** If two solves against the same host in one session do not clear an interstitial, a third is refused rather than paid for. A challenge that is not yielding twice is not going to yield on a third identical attempt. Both refusals are ordinary tool errors, so the agent reads them and can try something else, rather than the run dying. ## Adding a challenge type [#adding-a-challenge-type] Worth knowing if you are reading the source: a challenge type is one strategy class plus a `@register` decorator, and nothing else in the tree changes. Detection runs every strategy's pure `detect` over the same probe snapshot and ranks whatever claims it, so adding a type never means another branch in a growing chain. --- # The openbrowse command (/docs/cli) Installing the `openbrowse` package puts a single command on your `PATH`. It is the same command whether OpenBrowse came from PyPI or from a git checkout, and it works from any working directory, because nothing about it depends on where you happen to be standing. ```bash openbrowse --help openbrowse --version ``` With no subcommand it prints the help and points at `openbrowse start`, which is what most people want. ## The subcommands [#the-subcommands] | Command | What it does | | -------------- | ------------------------------------------------------------------------------------------------------------------------ | | `start` | Registers OpenBrowse as a systemd service, then enables and starts it, so it is running now and again after every reboot | | `stop` | Stops the service. `--disable` also stops it starting on boot | | `restart` | Restarts the service | | `status` | Shows `systemctl status` for the service | | `serve` | Runs the server in the foreground until you interrupt it | | `version` | Prints the installed version | | `check-update` | Asks PyPI whether a newer release exists and says so | | `update` | Installs the newer release, if there is one | | `tune` | Sizes the host for OpenBrowse. Linux only, and needs root | | `uninstall` | Removes everything OpenBrowse put on this machine, then the package itself | Every one of them exits non-zero on failure, so they compose in a script. ## Running the server [#running-the-server] `openbrowse serve` runs in the foreground and is what the systemd unit itself invokes. It takes the bind address and port from your configuration unless you override them: ```bash openbrowse serve --host 127.0.0.1 --port 9000 ``` `--port 0` means an ephemeral port, and is honoured as such rather than being read as "no port given". `openbrowse start` is the one to reach for on a machine that should keep running it. It needs root to write the unit and enable it, so it asks for your password, and it says in plain words whether OpenBrowse will now come back on boot. Where systemd is absent it says so and falls back to running in the foreground. The service is covered in full under [running it as a service](/docs/installation#run-it-as-a-service), including what happens when you already have a unit of your own. ## Updates [#updates] ```bash openbrowse check-update openbrowse update ``` `check-update` reports the installed version against the latest on PyPI and exits non-zero only if the check itself failed, so "already up to date" is a success. `update` runs the upgrade through whichever manager owns this copy, `uv tool upgrade`, `pipx upgrade`, pip inside its virtual environment or a `git pull`, and then tells you to restart; unlike the dashboard's button, it does not restart the server for you. Where the owning manager cannot be reached it names the install method and declines rather than running something that would fail. [Updating](/docs/installation#updating) has the full table. The dashboard does the same two things without a shell: a badge in the navigation when a release is waiting, and a one-click **Install and restart** on the Settings page that is refused while any session is running. [Updating](/docs/installation#updating) covers both paths and the one thing to do afterwards. ## Tuning the host [#tuning-the-host] ```bash openbrowse tune --share most --dry-run openbrowse tune --share most ``` `--share` takes `all`, `most` or `shared`, matching the three presets the setup screen and the Settings page offer, and `--dry-run` prints the plan without touching anything. The command runs a script bundled inside the package and needs root, so it asks for your password on a host where you are not already root, rather than failing with a traceback. What it actually writes, and why a Raspberry Pi needs it, is under [sizing it for your machine](/docs/installation#sizing-it-for-your-machine). Run it again after every upgrade: the sudoers grant it writes names the script by its full path, and that path moves with the package. ## Removing it [#removing-it] ```bash openbrowse uninstall ``` It prints exactly what it is about to remove and then asks you to type `uninstall` before it does anything: the systemd unit and its capacity drop-in, the tuning sudoers grant, the `psi=1` boot flag if one was added, your data directory including `.env` and your profiles, the downloaded browser, and finally the package. `--keep-data` and `--keep-browser` spare the last two of those, and `--yes` skips the prompt for a scripted teardown. A source checkout is left where it is. Removing a clone is `rm -rf` on a directory you chose, and guessing at that is not a thing an uninstaller should do. Your data directory holds the profile cookie jars, which are live credentials for every site those profiles are logged into, and they are not recoverable once removed. Pass `--keep-data` if you intend to reinstall. ## Where it reads and writes [#where-it-reads-and-writes] Every subcommand resolves one home directory holding `.env` and `data/`: `~/.openbrowse` for an installed copy, the repository root for a checkout, and whatever `OPENBROWSE_HOME` names if it is set. [Where OpenBrowse keeps its files](/docs/installation#where-openbrowse-keeps-its-files) has the detail, including why `OPENBROWSE_HOME` is the one variable that cannot live in `.env`. There is no walk up from the working directory looking for a `.env` to read, which matters now that the command runs from anywhere: an unrelated project's file can never end up supplying this server's keys. --- # How OpenBrowse works (/docs/concepts) This page is the map. Everything on it is observable in the live view, and each section links to the page that covers it in depth. ## One session, one real browser [#one-session-one-real-browser] When you create a session with a task, the instance allocates a **display slot**: a fresh Xvfb virtual display at 1920x1080, an `x11vnc` server attached to it, and a `websockify` bridge serving noVNC to your browser. It then launches a stealth-configured Chromium on that display, supplied by CloakBrowser, the library that provides the patched browser binary with its own user-data directory and a Chrome DevTools Protocol port, and connects the agent to it over CDP. This is why the live view shows a real browser rather than a replay: the stream is the actual X display the agent is working on. Port allocation is deterministic per slot: displays start at `:10`, VNC ports at `5900` plus the display number, noVNC ports at `6080`, and CDP ports at `9222`. The number of slots is `MAX_CONCURRENT_SESSIONS` (default 1). Over the cap the create call still returns straight away: the session is accepted and its run waits for a slot inside its own task, so a burst of submissions queues rather than blocking the caller or failing. A keep-alive session parked between follow-ups gives its slot up to a newly started run, oldest first, so a browser nobody is using cannot hold a one-slot host indefinitely. Two runs can never claim the same browser profile. The claim is taken inside the run rather than at request time, so the second session is accepted, reaches `running`, and then ends `error` with a message naming the session that holds the profile. If a `profileId` was given, the profile's storage state (cookies plus per-origin `localStorage` and `sessionStorage`) is loaded into the browser before the run, and written back when the session ends. See [profiles](/docs/profiles). ## The goal pre-flight [#the-goal-pre-flight] Before the agent takes its first step, a separate, cheap LLM call reduces your task to a single sentence stating what a complete and correct result looks like. That call asks for reasoning `none` where the model supports disabling it, and falls back to the model's own default where it does not, so on `claude-fable-5` and `claude-mythos-5` the pre-flight runs at their default of `high`. That sentence is pinned under the task as `GOAL:`, appears in the feed as a `goal` entry, and is re-injected into the conversation every ten steps. Long browser runs drift; the periodic reminder is what pulls a model back out of a rabbit hole. If the pre-flight call fails, the first sentence of your task is used instead. This is also why the first line of your task matters more than the rest; [writing tasks](/docs/tasks) covers how to use that. ## The step loop [#the-step-loop] Each step, the agent receives the page state (a serialised DOM element list plus a screenshot), reasons, and replies with prose fields and an `action` list of up to eight actions. The chain executes in order and is cut short at the first action that changes the page, so a step can batch several store or file writes but never acts on a stale view. OpenBrowse extends the stock browser-use step in a few measurable ways: * **The three cards.** The reply schema is patched to require three one-sentence fields before the action: `what_i_see`, `plan_to_goal` and `next_move`. They render as the see, plan and next cards in the feed, and they are required in the emitted schema but optional at validation, so a model that omits one never fails the step. * **Lean state between writes.** When a step did nothing but store, file or sandbox work and the URL has not changed, the next step receives a stub state, the URL, title and tab list, but no fresh DOM serialisation and no screenshot, labelled as code mode. This keeps long extraction phases from re-paying the cost of re-serialising an unchanged page on every step. Element indices from the last full state remain valid. * **Action repair.** A reply that carries no executable `action` field, or types an argument wrongly (a list sent as a quoted string, for instance), is retried up to twice with a corrective message naming the actual defect, and only then abandoned with a short instructive error rather than a raw validation dump. Anthropic responses additionally get an action-leak repair pass, parallel tool calls are merged back into one structured call, and truncated outputs are retried once with streaming and a 64,000-token ceiling. OpenAI models run against the Responses API, which accepts the full reasoning ladder; the output schema rides in the system prompt and the reply is parsed tolerantly. * **Timeouts that nest correctly.** The agent gives each LLM call 180 seconds, a sandbox script has 300, the bulk page reader has a 420-second budget, and the whole step has 520 seconds, so an outer timeout can never kill a well-behaved inner operation mid-flight. Underneath that, each provider client carries its own timeout: 180 seconds for Anthropic, and 90 for OpenAI, rising to 240 at reasoning `high`, `xhigh` and `max`. ## Purpose-built tools [#purpose-built-tools] The agent's registry is extended with tools tuned for visual, tab-based extraction, all of which you can watch working in the live view: * `find_links` collects a page's links by selector, and is the only tool that can read links inside an embedded, cross-origin panel. It scrolls the page (and any matching panel) until the link count stops growing before collecting, so lazily-populating lists are counted once, completely. * `read_pages` opens up to 48 URLs in parallel tab waves of up to six real foreground tabs at a time, waits for each page (and, when asked, its embedded panel) to genuinely render, and returns `{url, title, text, jsonld, links}` per page, retrying failures and detecting shell reads. It also prefills a draft row per page against your output schema. A wave paces itself only when the host is genuinely struggling, meaning two or more sessions active *and* the processor actually stalling, spacing tabs by up to 0.8 seconds each and no more than 8 seconds across the whole wave; a solo run never pays the gap. Covered in depth in [structured output](/docs/structured-output). * `run_code_file` saves and runs a Python script in one step against the live page, in a persistent sandbox namespace with a `browser` handle that can evaluate JavaScript inside cross-origin frames. The script's source streams into the code tab of the live view as the model writes it. * A session **clipboard** (`remember`/`recall`) persists small values across steps, and is pre-seeded with `startUrl` (the first URL found in your task) and the goal sentence. * `http_fetch` makes one server-side HTTP request; large responses are saved to a file and previewed rather than dumped into context. * `solve_captcha` is registered only when `CAPSOLVER_API_KEY` is configured, and each solve's real cost is folded into the session's total. A guard layer wraps the noisy tools so a run's context cannot balloon: any large output already seen this session is replaced with a short back-reference, and genuinely huge dumps are capped to a preview with the full data saved to a file. ## The answer store [#the-answer-store] If the task has an `outputSchema`, the schema is converted to a validation model and an empty **answer store** is built before the agent starts, so the finished shape is visible from turn zero. The agent fills it in place with validated writes rather than assembling one blob at the end; every write is checked live, coverage is tracked per field, and `done` is gated on the **completeness gate**: every field the schema requires must be either filled or explicitly marked absent before the agent is allowed to finish. This is the machinery behind the anti-hallucination behaviour, and it has its own page: [structured output](/docs/structured-output). ## The reviewer [#the-reviewer] When a run finishes with `done` and looks successful, a judge assesses the submitted result. If it requests changes, the review re-enters the agent as a continuation turn: the agent may apply the changes and resubmit, or push back with a justification (it gets two of those before the message demands the changes). The conversation is bounded at three rounds, and every verdict is recorded in the feed as an `event` whose category is `judge`, including a dissent note whenever the judge's verdict differs from the recorded outcome. One deliberate asymmetry: a run that dies before calling `done` but leaves behind a complete, schema-valid answer store is recorded as a success, with a feed entry saying so. The completeness gate is the arbiter, not the ceremony of finishing. ## Session lifecycle [#session-lifecycle] | Status | Meaning | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `created` | The session row exists but no task has been submitted yet | | `running` | A task is executing | | `idle` | On a `keepAlive` session: the last task finished, or hit its cost cap. Also where any session lands when stopped with strategy `task`. The session accepts a follow-up | | `timed_out` | Terminal: the run exceeded its own time limit | | `stopped` | Terminal: the task finished, was stopped, or hit its cost cap, in each case without `keepAlive` | | `error` | Terminal: the run failed, or the server restarted mid-run | | `expired` | Terminal: a `created` session was never given a task and was expired after 15 minutes | A follow-up task targets an existing session by passing `sessionId`, with `task` required. Only the fields you actually send are overwritten, so a follow-up that names just a `task` keeps the session's model, schema and budget. An ordinary session is addressable only while it is `idle` or `created`. A **keep-alive session is a conversation**, so it takes follow-ups after its browser has been released too, and that is the one exception to the rule. ## A keep-alive session is one worker [#a-keep-alive-session-is-one-worker] Setting `keepAlive` changes what persists between turns. The browser, the agent and its history stay alive, so a follow-up answers from what the session already knows rather than starting cold and re-reading the page. A session whose browser has since been released or evicted still works: the conversation so far is replayed into a fresh run. A parked browser holds real memory and a display slot, so a session nobody comes back to closes itself after `KEEP_ALIVE_IDLE_TIMEOUT` seconds, 600 by default, or never if you set it to `0`. Without `keepAlive`, the browser is torn down and relaunched between runs; what persists is the session record, its output, and the profile's cookie jar, not the live tabs. ## Why a run failed [#why-a-run-failed] A run that fails inside the agent loop carries a typed classification rather than leaving you to parse prose. `failureKind` names the cause and `failureStatusCode` carries the provider's HTTP status where there was one. An ordinary successful turn clears whatever an earlier failed run left behind. | `failureKind` | Meaning | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `provider_rate_limit` | The model provider rate-limited the request; `failureStatusCode` is usually 429 | | `provider_server_error` | The provider returned a 5xx | | `provider_connection_error` | The request never reached the provider | | `provider_timeout` | The provider accepted the request and did not answer in time | | `provider_error` | Some other error the provider returned a status code with | | `session_timeout` | The run exceeded its own time limit rather than the provider's | | `invalid_output` | The model's reply was truncated or otherwise unusable | | `budget_exceeded` | The run hit `maxCostUsd`; see [cost control](/docs/cost), which explains what is kept | | `agent_failure` | The agent gave up, **or the run never started**: a missing provider key and a session row with no task both land here before a single step runs | The classification reads the original exception and unwraps a wrapped provider error to its cause, which is what makes the first four distinguishable at all. That distinction is the point: a retrying caller can tell a transient provider blip apart from an agent that will fail again on the next attempt. Two things the table does not cover, both of which matter if you are treating `failureKind` as exhaustive: * **`failureKind` can be null on a failure.** A session errored by a server restart, or by a profile another running session already holds, is failed outside the classified path and carries no kind. `lastStepSummary` and the feed are the source of truth there. * **`failureKind` can be set on a success.** A run stopped by its cost cap that salvaged a complete, valid answer store is recorded successful *and* still carries `budget_exceeded`, because it did not finish on its own terms. Read `isTaskSuccessful` and `failureKind` together rather than treating either as the verdict on its own. `POST /v3/sessions/{id}/stop` takes a strategy: `task` cancels the current task and leaves the session `idle` for follow-ups, while the default `session` stops it outright. ## What runs where [#what-runs-where] Everything is one Python process plus per-session subprocesses (Xvfb, x11vnc, websockify, Chromium). State lives in SQLite at `data/browser_use.db`, profiles under `data/profiles/`, both relative to [OpenBrowse's home directory](/docs/installation#where-openbrowse-keeps-its-files), and the whole thing listens on port 8420. There is no queue service, no external database, and no cloud dependency beyond the LLM providers themselves. --- # Cost control (/docs/cost) OpenBrowse charges nothing itself. A run's cost is the LLM tokens it consumes at your provider's list prices, plus the per-solve fee of any CAPTCHA sent to CapSolver. The published [benchmark](/benchmarks) puts a full 14-record extraction between $0.22 and $1.62 depending on model and reasoning effort. ## How cost is computed [#how-cost-is-computed] After every step, the run's cost is recomputed from the **real token usage the provider reported**, priced per model: uncached input, cache reads, cache writes and output are each priced at their own rate, so the figure tracks what your provider bills rather than an estimate. Screenshots, tool schemas, fetched page content and sandbox output are all inside the API-returned token totals, so nothing needs adding on top. Prompt caching is always on and provider-managed: the system prompt and the latest state message are cache-marked for Anthropic, and OpenAI caches automatically server-side. This is a large part of why like-for-like token counts come out well under the hosted service's; the benchmark's matched pairing (same model, same reasoning) used 3.55x fewer tokens. Two pricing details the engine gets right that a naive estimate would not: * OpenAI's `gpt-5.6` models have a higher tariff for requests whose prompt exceeds 272,000 tokens; the engine applies the long-context rate exactly when the provider does. * CapSolver solves are priced from the cost field CapSolver itself returns per task, not a flat guess, and are folded into `totalCostUsd`. `CAPTCHA_MAX_COST_USD` (default `$0.03`) caps what a single **run** may spend on solving in total, not what one solve may cost; see [solving CAPTCHAs](/docs/captchas). Costs surface in three places: the session record's `llmCostUsd`, `totalCostUsd` and token counts (updated after every step, not just at the end), the per-step costs in the dashboard, and the run exports. Displayed dashboard figures round up to the whole cent, the amount actually charged. ## The hard cap: maxCostUsd [#the-hard-cap-maxcostusd] ```ts const session = await client.sessions.create({ task: "Capture every open vacancy and return the full schema for each.", model: "gpt-5.6-terra", reasoningEffort: "none", maxCostUsd: 3.0, outputSchema: mySchema, }); ``` `maxCostUsd` must be a finite number above zero. The cap is checked at the end of every step against the running total; when it is reached the run stops with `failureKind` `budget_exceeded` and a completion message of the form `Stopped: Cost $3.0121 exceeded budget $3.00`. The status is `stopped`, or `idle` on a `keepAlive` session, which stays addressable for follow-ups rather than ending. A caller polling a budgeted keep-alive session for `stopped` will wait for ever. It is a stop-loss, not a pre-flight reservation: the step that crosses the line completes, so the final figure can exceed the cap by up to one step's cost. ### A capped run keeps what it produced [#a-capped-run-keeps-what-it-produced] A budget stop resolves its output the same way an ordinary finish does, rather than discarding the work and charging you for nothing. The answer store's contents stand when the store holds them, `result.json` otherwise, and the run is recorded a success only when that output both validates against the schema and passes the completeness gate. Either way the output is written, so a partial is visible instead of vanishing, and a caller can see what was recovered before deciding whether to escalate. The salvage never calls the LLM. The budget that ended the run is the same budget schema repair would spend. One consequence worth knowing: a capped run whose store was complete is recorded successful **and** still carries `failureKind: "budget_exceeded"`. The two fields answer different questions, so read them together. ### On a keep-alive session, the cap is per dispatch [#on-a-keep-alive-session-the-cap-is-per-dispatch] Holding one agent across turns makes `maxCostUsd` a session-lifetime total, which would let a long conversation slowly strangle itself. Each dispatch therefore tops the pot back up by the allowance the session was created with. A session created with `maxCostUsd: 3` that has spent $2.40 runs its next task with a $5.40 ceiling, rounded up to the whole cent. Naming `maxCostUsd` on the follow-up itself overrides that for one dispatch, as an absolute ceiling rather than an increment. A session created without a budget stays unbudgeted. The bound is per task, not per conversation. ## Scaling caps written for hosted pricing [#scaling-caps-written-for-hosted-pricing] If your callers were written against Browser Use Cloud, their `maxCostUsd` values are priced for a service that charges platform fees on top of tokens. `CLOUD_MAX_COST_FACTOR` scales every incoming cap without touching the clients: set it above 0 and at most 1, and an incoming `$6` cap with a factor of `0.5` becomes a local `$3` budget. The scaled value is rounded up to the whole cent so a small cap can never collapse to zero, which would read as no budget at all. The session record echoes the local, scaled budget in `maxCostUsd`. The variable is validated at startup; an out-of-range or non-numeric value stops the server with a clear error rather than being silently ignored. ## What actually moves the bill [#what-actually-moves-the-bill] In benchmark order of leverage: 1. **Reasoning effort, on OpenAI models.** `gpt-5.6-terra` at `none` cost $0.24; the same model at `high` cost $0.66 for the same 14 records. Set `reasoningEffort` explicitly; see [choosing a model](/docs/models). 2. **Model choice.** The spread between the cheapest and dearest full extraction was 7.4x, with no difference in records recovered. 3. **Prompt scope.** A tight prompt finishes in fewer steps, and steps are where tokens go. The 36-step budget run and the 11-step focused run recovered identical data. 4. **Schema design.** Nullable fields and enums let the store settle absent data quickly instead of sending the agent hunting for values the site never publishes. Token-heavy behaviour inside a run is already managed for you: repeated large tool outputs are deduplicated into back-references, oversized dumps are capped to previews with the data saved to files, and steps that only do store or file work skip the screenshot and DOM re-serialisation entirely. ## Concurrency is a cost control too [#concurrency-is-a-cost-control-too] `MAX_CONCURRENT_SESSIONS` (default 1) bounds how many runs can spend at once. At the cap a session is still accepted, and its run queues for a slot rather than failing, so a burst of submissions cannot multiply your worst-case spend; it serialises it. --- # Exposing it safely (/docs/exposing) OpenBrowse runs on plain Python and Chromium and listens on a single port, so it port-forwards like anything else. That is rarely what you want. The instance holds your provider API keys and live session cookies for every site your agents log into, so putting it directly on the public internet gives an attacker a high-value target with one bearer token in front of it. [Tailscale](https://tailscale.com/) is the better default. It gives you two options, and the first is enough for most people. ## Private access from your own devices [#private-access-from-your-own-devices] ```bash tailscale up ``` Your instance is now reachable from any device on your tailnet, at a stable hostname, with no port opened on your router and nothing exposed publicly. Point your client at the tailnet hostname: ```ts const client = new BrowserUse({ apiKey: process.env.OPENBROWSE_API_KEY, baseUrl: "https://your-pi.tail0a1b2c.ts.net/v3", }); ``` Find the hostname with `tailscale status`. ## Public access over TLS [#public-access-over-tls] If the calling application does not live on your tailnet, for example a CI job or a hosted backend, Tailscale Funnel publishes the instance on the public internet over HTTPS with a certificate handled for you. ```bash sudo tailscale funnel --bg 8420 ``` `--bg` runs it as a background daemon managed by Tailscale, so it survives reboots without a separate systemd unit. ```bash tailscale funnel status curl https://your-pi.tail0a1b2c.ts.net/health ``` Turn it off again with: ```bash sudo tailscale funnel --bg off ``` Funnel makes the instance publicly reachable. The only thing between the internet and your agents is `API_KEY`, so make it long and random, rotate it if it ever appears in a log or a shell history, and prefer private tailnet access whenever the caller can reach it. ## What the server does on its own [#what-the-server-does-on-its-own] Failed authentication attempts are throttled per client IP, and a throttled request comes back as `429` with a `Retry-After` header saying how long to wait: the first five failures are free, then each further failure doubles a lockout interval, starting at one second and capped at fifteen minutes, until a successful authentication clears the record. The throttle honours `X-Forwarded-For`, so it identifies real visitors correctly behind Funnel or a reverse proxy. It covers the API, the dashboard, and the VNC websocket handshake alike, and its state is in memory, resetting on restart. This blunts online guessing; it does not substitute for a strong key. ## Hardening [#hardening] * **Generate `API_KEY` with a CSPRNG.** `python3 -c "import secrets; print(secrets.token_urlsafe(32))"`. Do not reuse a key from anywhere else. * **Set `DASHBOARD_PASSWORD` explicitly.** It defaults to `API_KEY`, which means anyone who has the API token also has the dashboard, including the live browser view of every session. * **Never set `ALLOW_INSECURE_NO_AUTH` on an exposed instance.** It exists for local development only and disables every check described above. * **Cap spending.** Every session accepts `maxCostUsd`, and `CLOUD_MAX_COST_FACTOR` scales incoming caps down if your callers were written against hosted pricing. See [cost control](/docs/cost). * **Keep `data/` off backups you do not control.** It contains cookie jars that are live credentials for the sites your profiles are logged into. * **Prefer HTTPS.** Funnel is HTTPS only. If you terminate TLS yourself, do not fall back to plain HTTP for convenience; the bearer token travels on every request. ## Next [#next] [Profiles](/docs/profiles) covers keeping agents logged in, including importing existing Browser Use Cloud profiles. --- # Getting started (/docs) OpenBrowse is a self-hosted replacement for Browser Use Cloud. It serves the same v3 REST API that the `browser-use-sdk` client already speaks, so an existing integration moves across by changing two lines. Almost everything else, retry logic, polling, profile ids and output schemas, stays exactly as it is; the handful of things that do not are listed below. It was built and benchmarked on a Raspberry Pi 5 with 16GB of RAM, and runs on any Debian or Ubuntu machine, including a VPS. You pay only for LLM tokens; there is no per-task platform fee. ## Before you start [#before-you-start] You need a Debian or Ubuntu machine with SSH access, and an API key from Anthropic or OpenAI, or both. Tailscale is optional but is the easiest way to reach the box from outside your network. ## Install [#install] The agent drives a real browser on a virtual X display and streams it over VNC, so those packages have to be there before anything will run. A fresh Raspberry Pi OS image also has no `uv`, so the last line installs it; skip that line if you would rather use pipx or a virtual environment below. ```bash sudo apt update sudo apt install -y xvfb x11vnc novnc websockify \ libnss3 libatk-bridge2.0-0 libdrm2 libxcomposite1 libxdamage1 \ libxrandr2 libgbm1 libpango-1.0-0 libasound2 libxshmfence1 libgtk-3-0 curl -LsSf https://astral.sh/uv/install.sh | sh ``` Then install OpenBrowse and start it: ```bash uv tool install openbrowse openbrowse start ``` `pipx install openbrowse` works the same way if you prefer it. The pip route needs a virtual environment you create and activate first, because Raspberry Pi OS and Debian mark the system Python as externally managed and refuse a bare `pip install`. [Installation](/docs/installation) covers all three. `openbrowse start` registers it as a systemd service, so it is running now and comes back after every reboot. `openbrowse stop --disable` undoes that, and `openbrowse status` and `openbrowse restart` manage it in between. On a machine without systemd it runs in the foreground instead. To work on OpenBrowse rather than only run it, clone the repository and run it out of the checkout: ```bash git clone https://github.com/lujstn/openbrowse.git cd openbrowse && uv sync uv run openbrowse serve ``` Open `http://:8420`. A fresh install takes you straight to a one-time setup wizard at `/setup`, which walks you through a dashboard password, a model provider key, how much of the machine OpenBrowse may use, and the API bearer key it generates for you, writes `.env`, downloads the browser build up front, and ends by restarting so the whole configuration is live. Once any credential exists the setup routes disappear and the dashboard asks for the password you chose. That `.env`, and the database and profiles beside it, live in `~/.openbrowse` for an installed copy and in the repository root for a checkout. [Where OpenBrowse keeps its files](/docs/installation#where-openbrowse-keeps-its-files) covers the difference and the variable that overrides it. Confirm the API is up: ```bash curl http://:8420/health ``` ```json { "status": "ok" } ``` ## Run your first task [#run-your-first-task] Create a session with a task. The API accepts the key either as a bearer token or in the `X-Browser-Use-API-Key` header the SDK sends; both work everywhere. ```bash curl -X POST http://:8420/v3/sessions \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "task": "Open https://news.ycombinator.com and return the titles of the top five stories.", "model": "claude-sonnet-5" }' ``` The response carries the session `id` and a `status` of `created`. It does **not** yet carry a `liveUrl`: the instance still has to allocate a virtual display and launch Chromium, which takes a few seconds, and only then does the status become `running` and `liveUrl` get filled in. Poll the session until it reaches a terminal status: ```bash curl http://:8420/v3/sessions/ \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" ``` A healthy run looks like this while it is working, then like this when it is done: ```json { "id": "5a2f...", "status": "running", "liveUrl": "/vnc/5a2f.../view?path=vnc/5a2f.../websockify", "output": null } ``` ```json { "id": "5a2f...", "status": "stopped", "isTaskSuccessful": true, "output": { "stories": ["...", "..."] }, "totalInputTokens": 38911, "totalOutputTokens": 2322, "totalCostUsd": "0.06", "lastStepSummary": "Task completed successfully" } ``` Three details in there catch people out. `liveUrl` is a path on your own instance, not an absolute URL, and the `?path=` parameter is what points noVNC at the session's socket, so it has to survive whatever you do with it. `output` comes back already parsed when it is valid JSON, so there is nothing to `JSON.parse`. And the cost fields are strings rather than numbers, which is deliberate: they are exact decimal values, not floats. When `status` is `stopped` (or `idle`, if you set `keepAlive`), the result is in `output`, with `isTaskSuccessful`, token counts and the exact LLM cost alongside it. If `status` comes back `error`, open the dashboard at `http://:8420/` and read that session's step feed. The failure is almost always stated there in plain text, which is faster than working backwards from the API response. The dashboard is also where you watch the live browser view while a run is in progress. ## Point your client at it [#point-your-client-at-it] ```ts import { BrowserUse } from "browser-use-sdk/v3"; const client = new BrowserUse({ apiKey: process.env.OPENBROWSE_API_KEY, baseUrl: "https://your-host/v3", }); const session = await client.sessions.create({ task: "Find every product on this page and return the structured list.", model: "claude-sonnet-5", outputSchema: mySchema, }); ``` Everything downstream of that survives untouched: retry logic, polling, profile ids, output schemas and cost caps. The instance also understands the cloud's `thinkingLevel` field and maps it onto its own [`reasoningEffort`](/docs/models), so a client that sets a reasoning depth keeps working too. ## What does not carry over [#what-does-not-carry-over] Most of a Browser Use Cloud integration survives the move untouched. A handful of things do not, and one of them will change your results silently rather than erroring, so it is worth five minutes before you migrate anything: Browser Use Cloud routes sessions through a managed US residential proxy by default, so the sites your agent visits currently see a residential IP. Here they will see your server. If your jobs touch anything geo-gated, rate-limited by IP, or fussy about datacentre ranges, test that first. Nothing will error; the pages will just come back different. Alongside the proxy, ten request fields are accepted and then ignored, fourteen of the cloud's model names are rejected outright, omitting `model` gets you a different one than it does on the cloud, and `liveUrl` arrives a few seconds later here than it does there. [Migrating from Browser Use Cloud](/docs/migrating) is the complete list, with what to do about each. ## Where to next [#where-to-next] * [Migrating from Browser Use Cloud](/docs/migrating) is the page to read before you move an existing integration. * [Installation](/docs/installation) covers system packages, the live view, running under systemd, and how updates arrive. * [The `openbrowse` command](/docs/cli) is the reference for every subcommand. * [How OpenBrowse works](/docs/concepts) explains the machinery: the step loop, the answer store, and the reviewer. * [Writing tasks](/docs/tasks) is the highest-leverage page here; prompt shape changes results more than model choice does. * [Structured output](/docs/structured-output) covers `outputSchema` and the validated answer store. * [Cost control](/docs/cost) explains what a run costs, and what `maxCostUsd` does on a conversation. * [Solving CAPTCHAs](/docs/captchas) lists which challenge types are solved, which are only recognised, and what solving costs. * The complete v3 surface is in the [API reference](/docs/api). --- # Installation (/docs/installation) The quick start on the [getting started](/docs) page is enough to try OpenBrowse. This page covers the full install: the system packages behind the live browser view, every configuration variable the application actually reads, running it as a service that survives reboots, and how updates arrive afterwards. ## What you need [#what-you-need] A Debian or Ubuntu machine with SSH access. OpenBrowse was built and benchmarked on a Raspberry Pi 5 with 16GB of RAM, and runs on any comparable VPS. Budget roughly **2GB of RAM and one CPU core per concurrent session**. Chromium itself accounts for 400 to 600MB of that; the rest covers the pages it loads, the virtual display it draws into, and the Python process. Concurrency is bounded by memory rather than by anything in the application. You also need an API key from [Anthropic](https://console.anthropic.com/), [OpenAI](https://platform.openai.com/api-keys), or both. ## System packages [#system-packages] The live view works by running Chromium against a virtual X display and streaming it over VNC, so three separate pieces have to be present: the display (`Xvfb`), the VNC server (`x11vnc`), and the websocket bridge (`websockify` with the noVNC assets) that your browser connects to. Each session gets its own trio, allocated when the session starts and released when it ends. ```bash sudo apt update && sudo apt upgrade -y sudo apt install -y xvfb x11vnc novnc websockify sudo apt install -y python3-venv python3-pip ``` Chromium needs its own set of shared libraries. Installing these up front avoids a launch failure that otherwise only appears when you start your first session: ```bash sudo apt install -y \ libnss3 libatk-bridge2.0-0 libdrm2 libxcomposite1 libxdamage1 \ libxrandr2 libgbm1 libpango-1.0-0 libasound2 libxshmfence1 libgtk-3-0 ``` ## Install OpenBrowse [#install-openbrowse] OpenBrowse is published to PyPI as `openbrowse`. Three installers are equally supported, and each puts the `openbrowse` command on your `PATH`: ```bash # uv uv tool install openbrowse # pipx pipx install openbrowse # venv source /bin/activate pip install openbrowse ``` [uv](https://docs.astral.sh/uv/) and [pipx](https://pipx.pypa.io/) each keep the app in an isolated environment they manage for you, and both know how to upgrade it later. If you have neither, `curl -LsSf https://astral.sh/uv/install.sh | sh` installs uv. The pip route needs a virtual environment, and the activation line above is not optional. Raspberry Pi OS and Debian mark the system Python as externally managed, so `pip install` outside a virtual environment is refused by the distribution rather than by us. The rest of this page assumes only that `openbrowse` runs. To work on OpenBrowse rather than only run it, clone the repository instead and run it out of the checkout. Everything below behaves the same, except that the checkout is its own home directory: ```bash cd ~ git clone https://github.com/lujstn/openbrowse.git cd openbrowse uv sync ``` ## Where OpenBrowse keeps its files [#where-openbrowse-keeps-its-files] One directory holds `.env` and everything under `data/`, and which one it is depends on how you installed: | How you installed | Home directory | | ------------------------------------ | ------------------------------ | | `uv tool install`, `pipx`, or `pip` | `~/.openbrowse` | | A git checkout | The repository root, as before | | Anything, with `OPENBROWSE_HOME` set | Whatever that names | The distinction exists so that an installed copy never writes into its own `site-packages`, where the next upgrade would delete it. `OPENBROWSE_HOME` overrides both and is the one setting that cannot live in `.env`, because it decides which `.env` gets read: export it from your shell or set it in the systemd unit. A command run under `sudo` still resolves to the invoking user's home rather than root's, because `SUDO_USER` is consulted before `HOME` is. So `sudo openbrowse start` reads the `.env` you wrote instead of creating a second, empty, root-owned copy of it beside your real one. ## The browser binary [#the-browser-binary] OpenBrowse does not drive the Chromium from `apt`. It drives a stealth-configured build supplied by **CloakBrowser**, the library that provides the patched binary, and that binary is downloaded the first time a session launches rather than during installation. That download is around 200MB and takes several minutes on a Raspberry Pi, so nothing is left to discover it mid-session. The setup wizard fetches it as one of its steps, with progress on screen, and the server starts a fetch at boot if the binary is still missing. A session launch waits for whichever fetch is in flight to finish before starting Chromium, rather than racing it: on SD-card hardware a download and a browser launch competing for the same disk starve Chromium of I/O until its debug port misses the readiness window, which surfaces as a launch failure with no obvious cause. For the same reason OpenBrowse sets `CLOAKBROWSER_AUTO_UPDATE=false` at startup, so the library never pulls a new build underneath a running launch. Updates to the binary happen at boot or during onboarding, deliberately, or not at all. From a checkout you can also fetch it by hand: ```bash uv run python -c "import cloakbrowser; print(cloakbrowser.ensure_binary())" ``` The command prints the path to the binary and exits. ## Configuration [#configuration] The simplest path is to start the server and let it configure itself. An unconfigured instance sends any visit to the dashboard on to a one-time setup wizard at `/setup`, which takes four steps and then writes `.env` for you: | Step | What it does | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | Dashboard password | The password you will sign in with, at least 8 characters. The username is `admin` | | Model provider | An Anthropic or OpenAI key, and optionally a CapSolver one. At least one of the two model providers is required; CapSolver does not count towards that | | Capacity | How much of this machine OpenBrowse may have, which sets the concurrency limit and the lighter browser profile | | API key | The bearer token it generates for your v3 clients | | Host tuning | Offers the `openbrowse tune` command for this machine. Optional, and you tell the wizard whether you ran it | | Browser download | Fetches the stealth Chromium build now, with progress, rather than leaving it to your first session | | Restart | Applies the configuration and hands you to the dashboard | Each provider key is checked against the provider itself before the wizard accepts it, so a typo is caught here rather than at your first session. A key the provider rejects blocks the step; a provider that cannot be reached from this machine is reported as exactly that, because an unreachable API says nothing about whether the key is good. The wizard refuses to overwrite a non-empty `.env`, and once any credential exists it redirects to the dashboard instead, so a configured instance never exposes it. It needs JavaScript; without it, write `.env` by hand as below. Before it asks for anything it checks that this machine has the roughly 2GB of free disk the browser build needs and that `Xvfb` is installed, because both are cheaper to discover now than at your first session. The redirect exists because no default password ships. There is deliberately never a working credential that you did not choose, which means a fresh install has nothing to check a password against and asking for one would pose a question nothing could answer. The v3 API is left out of this: a programmatic call to an unconfigured instance still fails closed with a `401` rather than being sent to an HTML wizard. Configuration is read once at startup. The wizard adopts the credentials it just wrote into the running process, so the dashboard is usable the moment you finish, but the capacity settings are not adopted: the session pool and the browser flags are already built by then, and only a restart honestly applies them. That is why the wizard ends in one. Editing `.env` by hand always needs a restart. To configure by hand instead, create `.env` in [the home directory above](#where-openbrowse-keeps-its-files). These are the variables OpenBrowse itself reads. The browser layer also honours the `CLOAKBROWSER_*` overrides its own library defines, and `BROWSER_USE_ACTION_TIMEOUT_S` is set by the application at startup rather than read from your environment: | Variable | Description | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `API_KEY` | Bearer token authenticating API requests. Required; without it every API call returns 401 | | `ANTHROPIC_API_KEY` | Anthropic key, for `claude-*` models | | `OPENAI_API_KEY` | Optional. OpenAI key, for `gpt-*` models | | `DEFAULT_MODEL` | Optional. The model a request that names none runs on. Left unset, the default follows whichever provider key is configured, `gpt-5.6-terra` for OpenAI and `claude-sonnet-5` for Anthropic. The v3 API and the dashboard's run form both follow it | | `CAPSOLVER_API_KEY` | Optional. [CapSolver](https://capsolver.com/) key for CAPTCHA solving. Without it the CAPTCHA tool is simply not registered | | `CAPTCHA_MAX_COST_USD` | Optional. Ceiling on what one **run** may spend on CAPTCHA solving in total, default `0.03`. Once a run's solves have reached it, further solves are refused rather than attempted. Each solve's real cost is added to the session total | | `DASHBOARD_USER` | Optional. Dashboard username, default `admin` | | `DASHBOARD_PASSWORD` | Optional. Dashboard password. Defaults to `API_KEY`. The setup wizard requires at least 8 characters; a value you write here by hand is not measured | | `MAX_CONCURRENT_SESSIONS` | Optional. Concurrent sessions, default 1. Budget roughly 2GB RAM and one core each. Over the cap a session is accepted and queued rather than refused, and the create call returns straight away | | `KEEP_ALIVE_IDLE_TIMEOUT` | Optional. Seconds a finished `keepAlive` session waits, browser still open, for its next follow-up before closing itself. Default `600`; `0` parks indefinitely, until the session is stopped or its display slot is claimed by a new one | | `CHROME_LIGHT_FLAGS` | Optional. Set to `1` to start every browser in the lighter profile described under [sizing it for your machine](#sizing-it-for-your-machine). Default off | | `CLOUD_MAX_COST_FACTOR` | Optional. Scales an incoming `maxCostUsd` to local cost, for callers whose budgets were priced for a hosted service. Above 0 and at most 1; `0.5` turns a `$6` cap into `$3`. Default `1.0`. An out-of-range value stops the server at startup with a clear error | | `ALLOW_INSECURE_NO_AUTH` | Optional. Set to `1` to run without any authentication. Development only; never expose an instance configured this way | | `UPDATE_CHECK_HOURS` | Optional. How often to ask PyPI whether a newer release exists, in hours. Default `6`; `0` switches the background check off, and the dashboard's update badge with it | One more variable is read before `.env` is, so it has to come from the environment itself rather than from that file: | Variable | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `OPENBROWSE_HOME` | Optional. The directory holding `.env` and `data/`. Overrides both defaults above. Export it from your shell or set it in the systemd unit; a value written into `.env` can never take effect, and the dashboard's Settings page will not offer it as an editable row for that reason | Generate a strong `API_KEY`: ```bash python3 -c "import secrets; print(secrets.token_urlsafe(32))" ``` Treat `.env` as secret. So is everything under `data/`, which holds the SQLite database (`data/browser_use.db`), profile cookie jars (`data/profiles/`), and screenshots. Profile jars are live credentials for every site your profiles are logged into. ## Verify [#verify] ```bash openbrowse serve ``` You should see: ``` 2026-08-18 09:14:02,881 [INFO] openbrowse.main: Initializing database... 2026-08-18 09:14:03,140 [INFO] openbrowse.main: Server ready on 0.0.0.0:8420 INFO: Application startup complete. ``` The timestamped lines are the application's own; the bare `INFO:` prefix is uvicorn's. Note that `Server ready on` reports the host and port from your configuration, so if you passed `openbrowse serve --port` this line still shows the configured default while the server listens on the port you asked for. `--port 0` is honoured as a request for an ephemeral port rather than being read as "unset". Check the API is answering: ```bash curl http://:8420/health ``` ```json { "status": "ok" } ``` `/health` is unauthenticated. `/health/details` requires the API key and additionally reports how many sessions are running right now. Then open `http://:8420/` for the dashboard, which signs in with `DASHBOARD_USER` and `DASHBOARD_PASSWORD` over HTTP Basic auth. ## Run it as a service [#run-it-as-a-service] Running under systemd means it restarts on failure and comes back after a reboot. One command writes the unit, enables it and starts it: ```bash openbrowse start ``` It asks for your password, because writing into `/etc/systemd/system` and enabling a unit both need root, and it prints in plain words whether OpenBrowse will now come back on boot. `openbrowse status`, `openbrowse restart` and `openbrowse stop` manage it afterwards, and `openbrowse stop --disable` is the one that also stops it starting on boot. On a machine with no systemd, `openbrowse start` says so and runs in the foreground instead. The generated unit runs as the user who invoked the command, reads the `.env` in that user's home directory, and starts the `openbrowse` launcher by the path it is on rather than by whatever that path resolves to, so an upgrade that repoints a versioned symlink does not leave the unit pointing at a directory the upgrade removed. Follow the logs with `journalctl -u openbrowse -f`. ### Keeping a unit you wrote yourself [#keeping-a-unit-you-wrote-yourself] `openbrowse start` writes a unit only when none exists. If one is already there and its `ExecStart` differs from what this copy would write, it says so and keeps yours, because a hand-written unit tends to carry hooks and limits the generator knows nothing about. Replacing it is then a deliberate act: ```bash openbrowse start --reinstall-unit ``` The Tailscale integration is the usual reason to hand-write one. The generated unit has no tunnel hooks, so if you want the public tunnel to come up and down with the service, write this instead and leave it in place: ```ini [Unit] Description=OpenBrowse After=network.target tailscaled.service Wants=tailscaled.service [Service] Type=simple User= WorkingDirectory=/home//.openbrowse EnvironmentFile=/home//.openbrowse/.env ExecStart=/home//.local/bin/openbrowse serve ExecStartPost=+/usr/bin/tailscale funnel --bg 8420 ExecStopPost=-+/usr/bin/tailscale funnel --bg off Restart=on-failure RestartSec=5 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target ``` The two `tailscale` lines bring the public tunnel up and down with the service; see [exposing it safely](/docs/exposing). On a source checkout, point `WorkingDirectory`, `EnvironmentFile` and `ExecStart` at the repository and its `.venv` instead. Either way the file belongs at `/etc/systemd/system/openbrowse.service`, and `openbrowse start` will then enable and start it without touching its contents. If the server is killed mid-run, sessions that were executing are marked as errored with the note `Interrupted by server restart` at the next startup, so nothing is ever left claiming to run forever. ## Updating [#updating] A background task asks PyPI every `UPDATE_CHECK_HOURS` whether a newer release exists. When one does, an **Update available** badge appears in the dashboard's navigation and the Settings page grows a software update card with a one-click **Install and restart**. The install is refused while any session is running, so an upgrade never interrupts a live browser. The same thing from a shell: ```bash openbrowse check-update openbrowse update ``` The upgrade runs through whichever manager owns this copy, rather than through a single command that happens to work most of the time: | How it was installed | What the upgrade runs | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `uv tool install` | `uv tool upgrade openbrowse` | | `pipx install` | `pipx upgrade openbrowse` | | pip, in a virtual environment | `pip install --upgrade openbrowse`, or `uv pip install --upgrade` where the environment has no pip of its own | | pip, into your user site | `pip install --user --upgrade openbrowse`, so it upgrades the copy that is actually running rather than the system one | | A git checkout | `git pull --ff-only`, then `uv sync` | A uv tool and a pipx app both live in a virtual environment, so both are recognised before the plain pip case; upgrading either with a bare pip would reach around the manager that owns it and leave its records describing a version that is no longer installed. Where the manager's own binary cannot be found, or a copy sits in the system Python where no upgrade is safe to run unattended, OpenBrowse names the install method and declines to invent a command rather than offering one that would fail on use. That detection follows the package itself, not `OPENBROWSE_HOME`, so pointing the home directory at some other folder cannot aim a `git pull` at an unrelated repository. From the shell the restart is yours to do; from the dashboard it happens for you. Run `openbrowse tune --share ` once after upgrading. The sudoers entry that lets the dashboard tune the host and restart the service names `host_tune.sh` by its full path, and that path moves with the package, so an upgrade silently leaves those buttons unable to do anything. The Settings page says so when it detects it, and re-running the command rewrites the entry. ## Sizing it for your machine [#sizing-it-for-your-machine] Concurrency that suits a Raspberry Pi strands a sixteen-core VPS, and the reverse thrashes the Pi, so the setup screen reads the machine before it asks you anything: * cores, total and available memory, and current load * whether the kernel exposes pressure stall information * whether this is a Raspberry Pi * whether systemd is in charge * whether the cgroup memory controller is available * whether the root filesystem is on an SD card * whether the capacity drop-in below has already been written The last three drive checklist rows rather than the slider. You then choose how much of the device OpenBrowse may have, and a slider recommends a session count bounded by what the hardware can hold. | Share | Meaning | Fraction of memory | | ------------ | ---------------------------------------------------- | ------------------ | | All of it | The machine's only job | 90% | | Most of it | A machine that also does something else occasionally | 70% | | A fair share | A machine with another real job | 40% | The same card is in Settings, so you can retune later without reinstalling, and a checklist alongside it shows which host-level steps are done and which remain. Probing degrades to a safe default anywhere it cannot read the machine, and nothing is recommended off a guess. The slider stops at **8 sessions** however large the machine is, and the recommendation drops by one on a host already running above 0.5 of load per core. Both are bounds on the *recommendation*, not on the software: `MAX_CONCURRENT_SESSIONS` set by hand in `.env` is not clamped, so a large VPS can go past 8 if you have the memory for it. ### The host-level half [#the-host-level-half] Two of those steps are outside the application: systemd does not know OpenBrowse should win a contended CPU, and on a Raspberry Pi the kernel ships with pressure stall information compiled out. `openbrowse tune` applies both in one idempotent command: ```bash openbrowse tune --share most --dry-run # show the plan openbrowse tune --share most # apply it ``` The work is done by a shell script bundled with the package, which needs root, so the command asks for your password rather than failing on a host where you are not already root. It writes a systemd drop-in that weights the service's CPU share and caps its memory at the fraction you chose, appends the `psi=1` boot flag where PSI is missing and this is a Pi, and adds a sudoers entry so the dashboard's one-click tuning and restart buttons work without a password. Every action prints what it did, repeats are skipped rather than rewritten, and `--dry-run` touches nothing. The memory cap applies from the next service restart; the PSI flag needs a reboot. The drop-in is written under `openbrowse.service.d`, matching the unit name the application itself restarts by. If you run OpenBrowse under a differently-named unit, run the bundled script directly with `--service ` so the two agree; otherwise the dashboard will keep reporting the tuning as still to do. That sudoers entry names the script by its full path, so it stops matching as soon as an upgrade moves the package. Re-run `openbrowse tune --share ` after every upgrade. The Settings page notices the mismatch and names the command, but a machine tuned once and upgraded later will otherwise sit there with dead buttons and no error. PSI matters because load average counts runnable processes, which mistakes a healthy burst of tabs for trouble and misses real starvation behind modest numbers. Where PSI is available, OpenBrowse instead measures the fraction of time runnable work sat waiting for a CPU, which is the thing that actually hurts. Hosts without it fall back to per-core load, and the telemetry names which signal it used. ### The lighter browser profile [#the-lighter-browser-profile] `CHROME_LIGHT_FLAGS=1` starts every browser without a GPU process, with renderer processes capped at four, a 256MB JavaScript heap per renderer, Chromium's low-end device mode, and no background networking. On hardware with no real GPU, drawing into a virtual display, those are close to free, and they lower the memory floor of every session. Setup pre-selects it where it earns its keep, meaning a Raspberry Pi, four cores or fewer, or 8GB of memory or less, and leaves it alone on a large host. It stays a checkbox either way. Site isolation is deliberately left intact. Collapsing it would save more memory again, and it would also break reading pages inside cross-origin frames, which is precisely the case the benchmark task exists to prove. The one trade-off to watch is the JavaScript heap: if a single heavy site misbehaves only with the profile on, that 256MB ceiling is the first thing to suspect. ## Next [#next] Point a client at it from [getting started](/docs), read the whole [`openbrowse` command](/docs/cli) surface, understand what actually happens during a run in [how OpenBrowse works](/docs/concepts), or read the [v3 API reference](/docs/api). --- # The live view and debugging (/docs/live-view) Every session is watchable while it runs and inspectable after it ends. The dashboard at `http://:8420/` (HTTP Basic auth, user `admin` unless you changed `DASHBOARD_USER`) shows a live session list; each session page combines the browser view, the step feed and the export controls. ## The browser view [#the-browser-view] The stream is a noVNC client attached to the actual X display the agent's Chromium is running on, so what you see is the browser itself, not a reconstruction. Parallel tab waves from `read_pages` are visible as tabs opening, being focused one by one, and closing. The session's `liveUrl` field carries the same view as a path on your instance, served through the dashboard's authentication, so you can link an operator straight to a running session. The view starts when somebody looks. A session allocates only the virtual framebuffer it needs to run; the VNC server and the websocket bridge spawn on the first request to view it, from either the sessions grid or the session page. Runs therefore start faster, and an unwatched run does not stream pixels to nobody. The first frame after you open a view can take a moment longer than the ones after it. When the agent runs a sandbox script, it opens a dedicated **code tab** on the same display: an IDE-style page into which the script's source streams token by token as the model writes it, with its status and the file name. The tab closes when the script finishes. This makes the run's code layer as observable as its clicking layer. ## The step feed [#the-step-feed] Each feed row is one message from the run. The row's badge tells you which layer produced it: | Type | Meaning | | ---------------------- | -------------------------------------------------------------------------------------------------------- | | `planning` | Run start: which model the session launched with | | `browser_action` | A completed step: the primary action, its key parameter, and the step duration | | `result` | A step whose action returned content, such as a page read or a store write summary | | `browser_action_error` | A step that failed; the row shows the first sentence and the expandable card carries the full error text | | `event` | Annotations that are not steps: goal reminders, coverage checks, read progress, reviewer verdicts | | `completion` | The final outcome line | Those six are what a run itself produces. A seventh, `user_message`, records a follow-up task sent to a keep-alive session. `GET /v3/sessions/{id}/messages` applies no type filter at all, so match on all seven if you are filtering programmatically. Two names people reach for do not exist. There is no `error` type; failures are `browser_action_error`. And `done` is not a type either: it is an action name carried in a row's `data.action` when the agent submits its result for review, and that row is typed `result` rather than `browser_action`, because any step whose action returns content is recorded as a result. Steps expand to show the agent's three cards, **see** (what is on the page), **plan** (how it gets from here to the goal) and **next** (the next single move), plus the model's own chain-of-thought summary when the provider exposes one. That reasoning arrives as its own `event` row per step, with category `reasoning`. Events carry a category that tells you which subsystem is speaking: | Category | What it reports | | ----------- | ------------------------------------------------------------------------------------------------------------------------- | | `goal` | The derived goal sentence at the start, then a reminder every ten steps | | `reasoning` | The provider's own chain-of-thought summary for the step, when the model exposes one | | `memory` | Clipboard writes, including the auto-captured `startUrl` | | `read` | `read_pages` progress, wave by wave: `read_pages wave 2/3: 12 of 12 pages ok, 12 read inside their embedded panel (38s)` | | `code` | Sandbox script starts, such as `Running map_rows.py` | | `schema` | Completeness gate activity: the bounce with its deficiency list, then `Completeness check passed` with the final coverage | | `judge` | Reviewer verdicts, change requests, and any dissent between the judge and the recorded outcome | | `system` | Host pressure warnings when the processor is stalling at launch, which degrade timing-sensitive embed reads | ## Reading what is happening right now [#reading-what-is-happening-right-now] Above the feed, one activity surface answers "what is the run doing at this instant?". A phase declares which kind it is, and the two kinds look different on purpose: * **A phase that thinks shimmers**, and the model's reasoning streams underneath it as it is generated, rather than as a truncated tail of it. A finished thought collapses to how long it took, with a chevron to read it again. * **A phase that acts spins.** Running actions, preparing the next step, reading pages, running code and solving a CAPTCHA all turn a spinner. Nothing wears both, so a shimmer means the model is reasoning rather than merely that something is happening. Adaptive thinking can open a block and then say nothing for tens of seconds, so the label goes up on the block itself and the streaming card takes over the moment real text arrives. The elapsed time a finished thought reports is the same figure the durable feed row beneath it carries, so reopening a session later says what the live view said at the time. On a keep-alive session, each finished turn's completion folds away under its own title as the next begins, newest on top and open, and a reload rebuilds that stack. Every turn's answer stays reachable rather than being overwritten by the next one. ## Diagnosing a run from the feed [#diagnosing-a-run-from-the-feed] The feed usually shows the problem directly. Things worth scanning for: * **`find_links` matched 0 links, or a frame filter matched 0 frames.** The listing lives in an embedded panel that had not attached yet, or the selector was wrong. The tool's own error text says which, and lists the attached frame hosts to target instead. * **`read_pages` reporting shell reads.** Rows like `read the embedding shell, not this page's real content` mean the pages' content lives in a cross-origin embed; the retry inside the panel happens automatically and is logged, so check whether the recovery line follows. * **A completeness bounce.** The `schema` event lists exactly which fields were empty and on how many items. If the run then finished cleanly, nothing was wrong; the gate did its job. If it looped into `mark_absent` on data you know is there, the pages that show it probably never rendered; look for read failures above. * **`Stopped: Cost $X exceeded budget $Y`.** The cost cap fired, and the session carries `failureKind: "budget_exceeded"`. Whatever the run had produced is kept; see [cost control](/docs/cost). * **A failed session's `failureKind`.** Before reading the feed at all, check it. A `provider_rate_limit` or `provider_server_error` says the run failed for a reason that has nothing to do with your task or the site, and is worth simply retrying. [How OpenBrowse works](/docs/concepts) lists the full set. * **A `system` pressure warning at launch.** Failures in that run may be environmental (an overloaded host missing embed attach windows) rather than site changes. Re-run when the host is quiet before concluding anything. * **`Step timed out and was cancelled before completing`.** One step exceeded 520 seconds; the run continues, but repeated occurrences usually mean the host is underpowered for the page. ## Exporting a run [#exporting-a-run] The session page exports JSON at three scopes, which are also plain authenticated GETs: ```bash curl -u admin:$DASHBOARD_PASSWORD \ "http://:8420/session//log?scope=full&download=true" ``` | Scope | Contents | | -------- | ------------------------------------------------------------------- | | `output` | Only the schema answer, as JSON | | `steps` | The session record and every step, with raw model thinking stripped | | `full` | Everything the feed shows, including reasoning text | The `full` export is the right attachment for a bug report: it contains the task, every action with its parameters and duration, every error in full, and the reviewer's reasoning. The v3 API exposes the same underlying feed programmatically at `GET /v3/sessions/{session_id}/messages`, paginated with `after`, `before` and `limit`, which is what you want for driving your own progress UI. --- # Migrating from Browser Use Cloud (/docs/migrating) OpenBrowse serves the same v3 REST surface as Browser Use Cloud, so the client change is two lines. This page is about everything else: the handful of places where the two are not the same, ordered by how likely each one is to cost you an afternoon. If you are still deciding rather than migrating, the [dimension-by-dimension comparison](/vs/browser-use-cloud) covers what you gain and what you give up. ## The two lines [#the-two-lines] ```ts import { BrowserUse } from "browser-use-sdk/v3"; const client = new BrowserUse({ apiKey: process.env.OPENBROWSE_API_KEY, baseUrl: "https://your-host/v3", }); ``` The import matters. The bare `browser-use-sdk` entry point is the v4 client, whose runs API has a different request shape that OpenBrowse does not implement. The v3 client lives at `browser-use-sdk/v3` and its method is `sessions.create`, not `tasks.create`. The `baseUrl` needs the `/v3` suffix, because the SDK's own default base URL ends in `/api/v3` and its request paths are relative to it. Retry logic, polling, profile ids, output schemas and cost caps all carry over untouched. ## 1. There is no proxy [#1-there-is-no-proxy] Browser Use Cloud runs a managed US residential proxy by default, so target sites see a residential IP. OpenBrowse has no proxy layer at all: requests leave from your machine's own address. For most people that is a saving rather than a loss. A box on your home broadband **already has a residential IP**, which is the thing the cloud's proxy exists to give you, and residential egress is one of the more expensive parts of what you are paying a hosted runner for. Self-hosting on a Pi at home gets it as a side effect of where the machine is, at no extra cost and with no per-gigabyte meter running. The case where it genuinely bites is a VPS. A datacentre IP is exactly what sites blocking automated traffic look for first, and moving from the cloud's residential pool onto one is the change most likely to alter your results. Whichever you run on, this is the difference that changes results without erroring. Nothing throws; the pages simply come back different. Test anything geo-gated, rate-limited by IP, or fussy about datacentre ranges before you migrate the rest. `proxyCountryCode` is accepted so your code still compiles, and does nothing: there is no per-session proxy selection to reach for. If you do need one, put it in front of the instance at the network level, or keep those particular jobs on the cloud. Note that geo-targeting is the one thing a home connection cannot substitute for, since your IP is wherever you are. Making it mean something is planned, by letting a session borrow its browser from [Steel](https://steel.dev) rather than launching one locally. Not yet, though. ## 2. Ten request fields are accepted and ignored [#2-ten-request-fields-are-accepted-and-ignored] Three of them are declared in the request model and deliberately inert: | Field | What happens | | ------------------ | ----------------------------------------------------------------------- | | `proxyCountryCode` | Ignored, as above | | `enableRecording` | Ignored. Sessions are not recorded and `recordingUrls` comes back empty | | `skills` | Ignored. There is no skills or marketplace equivalent | The other seven are not declared at all, so they are dropped silently before your request is ever validated: | Field | Why it matters | | ---------------------- | -------------------------------------------------------------------------------------------------- | | `agentmail` | **The cloud defaults this to on.** A caller expecting a provisioned inbox gets none, and no error | | `codeMode` | Changes the shape of `output` on the cloud to `{text, code}`. Here `output` keeps its normal shape | | `workspaceId` | No workspaces exist. `workspaceId` comes back null | | `enableScheduledTasks` | No scheduler. Schedule from your own side | | `cacheScript` | No script cache | | `autoHeal` | No auto-heal behaviour | | `useOwnKey` | Always effectively true: the instance uses the provider keys in its own `.env` | `agentmail` and `codeMode` are the two worth checking for in your codebase, because both change what you get back rather than merely doing less. The matching response fields (`recordingUrls`, `screenshotUrl`, `workspaceId`, `proxyCountryCode`) are always empty or null. ## 3. Fourteen of the cloud's model names are rejected [#3-fourteen-of-the-clouds-model-names-are-rejected] Browser Use Cloud's v3 `model` enum has 22 names. OpenBrowse implements 8 of them. Naming one of the other 14 fails at request time with a 422 rather than falling back to a default. The cloud's own default, `claude-opus-4.7`, is supported here. It is not the *default* here, though, and that catches people the other way round: Omit `model` on the cloud and you get `claude-opus-4.7`. Omit it here and you get whatever the instance's `DEFAULT_MODEL` names, or, where that is unset, the default for whichever provider the instance has a key for, silently and without an error. On an OpenAI-configured instance that is a swap of provider as well as model, and it takes the reasoning depth with it either way. So a caller who names a model risks a 422, and a caller who relied on the cloud's default gets a quiet substitution instead. Set `model` explicitly and neither happens. **Supported on both:** `claude-opus-4.7`, `claude-opus-4.8`, `claude-opus-4.6`, `claude-sonnet-5`, `claude-sonnet-4.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`. **Rejected here, with the nearest equivalent:** | Cloud name | Here | | ---------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `bu-mini`, `bu-max`, `bu-ultra` | No equivalent. These are tiers rather than models; pick a model by name | | `gemini-3-flash`, `gemini-3-pro`, `gemini-3.1-pro`, `gemini-3.5-flash` | Google is not supported yet | | `gpt-5.2`, `gpt-5.5`, `gpt-5-mini`, `gpt-5.4-mini` | Use `gpt-5.6-sol`, `gpt-5.6-terra` or `gpt-5.6-luna` | | `claude-haiku-4.5` | Use `claude-sonnet-4.6` | | `glm-5.2`, `minimax-m3` | No equivalent | OpenBrowse also serves models the cloud's v3 enum does not offer: `claude-opus-5`, `claude-fable-5`, `claude-mythos-5`, and a `[1m]` long-context variant of every Anthropic name. Of those, the cloud reaches `claude-opus-5` and `claude-fable-5` through its v4 runs API only, and has no equivalent of `claude-mythos-5` or the `[1m]` variants at all. The full list is on [choosing a model](/docs/models). ## 4. `thinkingLevel: null` is rejected [#4-thinkinglevel-null-is-rejected] The cloud's `thinkingLevel` field works here and maps onto `reasoningEffort`, with `disabled` becoming `none`. One exception: the cloud documents `thinkingLevel: null` as the way to clear the setting, and here an explicit `null` is read as a value rather than as an absence, so it fails with `'None' is not a supported thinkingLevel`. Omit the field entirely instead. ## 5. `liveUrl` is not immediate [#5-liveurl-is-not-immediate] On Browser Use Cloud, `liveUrl` is available the moment a session is created. Here, creating a session returns `status: "created"` with `liveUrl` still null; the instance then allocates a virtual display and launches Chromium, and only then does the status become `running` with `liveUrl` populated. That is a few seconds. If your integration reads `liveUrl` straight off the create response and embeds it, it will embed nothing. Poll until `status` is `running`. ## 6. The status sets are not identical [#6-the-status-sets-are-not-identical] A session created without a task and never given one is expired after fifteen minutes and moves to `expired`, which is not in the cloud's status enum. If your code switches exhaustively over the cloud's set, add a branch for it. `timed_out` exists on both, but reaches you differently: here it means the run exceeded *its own* time limit, and a run stopped by its cost cap ends as `stopped` rather than timing out, or as `idle` if it was created with `keepAlive`. ## 7. Two response fields the cloud does not have [#7-two-response-fields-the-cloud-does-not-have] A failed session here carries `failureKind` and `failureStatusCode`, which the cloud's v3 response model does not define. They distinguish a provider rate limit, a provider 5xx, a connection error and a provider timeout from a session timeout, invalid output, budget exhaustion and ordinary agent failure, so a retrying caller can tell a transient blip apart from an agent that will fail the same way again. Both are null on success. They are additive, so a client that ignores them behaves exactly as it did. See [how OpenBrowse works](/docs/concepts) for the full set of values. ## 8. There is nothing else running the machine [#8-there-is-nothing-else-running-the-machine] The cloud absorbs uptime and capacity. Here a box has to stay up, and concurrency is bounded by memory: budget roughly 2GB of RAM per concurrent session. [Installation](/docs/installation) covers `openbrowse start`, which registers it as a service so it survives a reboot, and [exposing it safely](/docs/exposing) covers reaching it from outside your network. Upgrades are the part that comes closest to the managed experience: the instance watches PyPI for new releases and installs one on a click from its own Settings page, refusing while a session is running. That is still your click rather than someone else's, and it is [covered here](/docs/installation#updating). ## What does carry over [#what-does-carry-over] Worth stating plainly, because the list above is longer than the list of things that break: * The whole `sessions` surface: create, get, list, stop, and follow-up tasks against an existing session. * `outputSchema` and structured output, with stricter validation here than on the cloud. * `maxCostUsd`, enforced as a hard stop-loss, though on a `keepAlive` session it is the pot for one dispatch rather than for the conversation; [cost control](/docs/cost) sets out how it tops up. If your caps were priced for a service charging platform fees on top of tokens, `CLOUD_MAX_COST_FACTOR` scales incoming caps down without touching your clients; see [cost control](/docs/cost). * Profiles. OpenBrowse imports the Playwright storage-state format a cloud profile export gives you, cookies plus per-origin `localStorage`, and an imported profile keeps its cloud id, so existing `profileId` references keep working. See [profiles](/docs/profiles). * Both authentication styles: `Authorization: Bearer ` and the `X-Browser-Use-API-Key` header the SDK sends. --- # Choosing a model (/docs/models) OpenBrowse puts Anthropic and OpenAI models behind one API, with a repair layer per provider for each family's failure modes. You pick the model per session, and if you send none the instance's `DEFAULT_MODEL` decides. Where that is unset the default follows whichever provider is actually configured, so an OpenAI-only instance runs `gpt-5.6-terra` and an Anthropic-only instance runs `claude-sonnet-5`. Where both keys are present OpenAI wins. The v3 API and the dashboard's run form both read the same setting, so a request gets the same model whichever door it came through. Version numbers may be spelled with dots or dashes, and both reach the same model: `gpt-5.6-terra` and `gpt-5-6-terra` resolve identically, as do `claude-sonnet-4.6` and `claude-sonnet-4-6`. Names are resolved through one index keyed on version punctuation, so a client that normalises identifiers on the way out does not get a 422 for it. ## The short answer [#the-short-answer] | Situation | Model | `reasoningEffort` | | ---------------------------- | -------------------------------- | ----------------- | | Most work | `gpt-5.6-terra` or `gpt-5.6-sol` | `none` | | Most work, Anthropic | `claude-sonnet-5` | `high` | | Hard, multi-step workflows | `claude-opus-5` | `medium` | | Tight budget, focused prompt | `gpt-5.6-luna` | `max` | These are also the levels a session runs at when you send no `reasoningEffort` at all, so the top row is what an out-of-the-box instance does with a request that names neither a model nor a depth. ## The finding worth knowing [#the-finding-worth-knowing] On browser tasks, **the two families want opposite ends of the reasoning dial.** OpenAI models do better with *less* reasoning. They spend less time planning ahead and more time reacting to the page actually in front of them, which is the correct instinct when the environment is a live DOM that changes under you. Anthropic's 5-series models lean towards rabbit holes and need reasoning time to refocus on the goal. The measured effect is large. On our extraction benchmark task: * `gpt-5.6-terra` at `none` finished in **11 steps and 1m 47s**. The same model at `high` took **17 steps and 5m 05s**, burning 2.1x the tokens for 2.75x the cost, and produced the same 14 records. * `claude-sonnet-5` cost **$0.40 at `high`** and **$0.51 at `none`**, and was 1m 16s slower with reasoning switched off. So the default instinct, that more reasoning means better results, is wrong for half of the models here. That is why an omitted `reasoningEffort` resolves to the level measured above rather than to whatever the provider does unprompted: on `gpt-5.6-terra` those two differ, and inheriting the provider's would triple the bill for the same fourteen records. Full numbers are on the [benchmarks page](/benchmarks). ## Two things called thinking [#two-things-called-thinking] **Browser thinking** is how the platform works: the see, plan, next and thinking cards in the live feed. It describes the agent's step loop and cannot be switched off. **Model reasoning** is the chain-of-thought the LLM provider exposes, such as Anthropic extended thinking or OpenAI reasoning effort. This is the one you control, per session, with `reasoningEffort`. ## The reasoning ladder, per model [#the-reasoning-ladder-per-model] `reasoningEffort` accepts `default`, `none`, `low`, `medium`, `high`, `xhigh` and `max`, validated per model at request time. An invalid value is rejected with a 422 naming the valid set for that model. Sending `default`, or omitting the field, is not the same as letting the provider decide. OpenBrowse resolves the level itself and sends it explicitly, using the benchmark-backed pick for that model where there is one and the provider's own default where there is not. The two columns below are separated because they genuinely disagree on four of the eleven models, and the dashboard's dropdown labels them the same way: **Default** marks the level a session actually runs at, and **Provider default** appears beside it only where the provider would have chosen differently. | Model | Accepted levels | Runs at when unset | Provider's own default | Can reasoning be disabled? | | ------------------- | ----------------------- | ------------------ | ---------------------- | -------------------------- | | `claude-sonnet-5` | `low` to `max` | `high` | `high` | yes | | `claude-opus-5` | `low` to `max` | `medium` | `high` | yes | | `claude-fable-5` | `low` to `max` | `high` | `high` | no | | `claude-mythos-5` | `low` to `max` | `high` | `high` | no | | `claude-opus-4.8` | `low` to `max` | `none` | `none` | yes | | `claude-opus-4.7` | `low`, `medium`, `high` | `none` | `none` | yes | | `claude-opus-4.6` | `low`, `medium`, `high` | `none` | `none` | yes | | `claude-sonnet-4.6` | `low`, `medium`, `high` | `none` | `none` | yes | | `gpt-5.6-terra` | `none` to `max` | `none` | `medium` | yes | | `gpt-5.6-sol` | `none` to `max` | `none` | `medium` | yes | | `gpt-5.6-luna` | `none` to `max` | `max` | `medium` | yes | This changed in 1.8.3. Before it, an omitted `reasoningEffort` inherited the provider's default, which is the fourth column. If you have been relying on that, name the level you want explicitly and nothing moves. A few provider mechanics behind that table: * The Claude 5 family and Opus 4.8 use adaptive thinking, and on the 5-series models the provider's own choice is adaptive thinking at high. This is why `none` must be an explicit choice there, and why `claude-fable-5` and `claude-mythos-5`, which do not accept a disabled configuration, reject it with `reasoning cannot be disabled on claude-fable-5`. * The older Claude models (`opus-4.7`, `opus-4.6`, `sonnet-4.6`) use fixed thinking budgets: `low`, `medium` and `high` map to 2,048, 8,192 and 16,384 thinking tokens. * OpenAI models run against the Responses API, which accepts the full `none` to `max` ladder (the chat completions endpoint rejects `max`, which is why OpenBrowse does not use it). ## Long context variants [#long-context-variants] Every Anthropic model name accepts a `[1m]` suffix, for example `claude-opus-4.8[1m]` or `claude-sonnet-5[1m]`, which requests the provider's 1M-token context window for that session. The suffix is stripped before the model is resolved, so it works on the 5-series names as well as the 4.x ones. Useful for runs that read many large pages into a single conversation; the long-context tier is priced higher by the provider. Dotted and hyphenated spellings of the 4.x names are interchangeable, so `claude-opus-4.8` and `claude-opus-4-8` resolve to the same model. ## Migrating from thinkingLevel [#migrating-from-thinkinglevel] Requests written for Browser Use Cloud's `thinkingLevel` field keep working: `disabled`, `low`, `medium` and `high` map onto `reasoningEffort` (`disabled` becomes `none`). Sending both fields in one request is rejected, as are the retired spellings `thinkingEffort` and `modelThinkingEffort`, each with an error naming `reasoningEffort` as the replacement. One exception, and it is easy to hit. Browser Use Cloud documents `thinkingLevel: null` as the way to clear the setting. Here an explicit `null` is treated as a value rather than as an absence, so it fails with `'None' is not a supported thinkingLevel`. Omit the field entirely instead. ## Supported models [#supported-models] **OpenAI:** `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` **Anthropic:** `claude-mythos-5`, `claude-fable-5`, `claude-opus-5`, `claude-sonnet-5`, `claude-opus-4.8`, `claude-opus-4.7`, `claude-opus-4.6`, `claude-sonnet-4.6`. Every one of those also accepts the `[1m]` suffix, so `claude-sonnet-5[1m]` and `claude-opus-4.6[1m]` are both valid. The suffix is stripped before the name is resolved and the long-context beta is requested for whichever Anthropic model it resolved to, so there is no per-model exception list to check. **Google:** not yet. Browser Use Cloud's v3 `model` enum has 22 names and OpenBrowse implements 8 of them, so a client that names a model explicitly may be naming one that fails here with a 422 rather than falling back. The cloud's default, `claude-opus-4.7`, is supported, so this only bites callers who set `model` themselves. See [migrating from Browser Use Cloud](/docs/migrating) for the mapping in both directions. `DEFAULT_MODEL` in the instance's `.env` decides which model a request that names none gets, and it is editable from the dashboard's Settings page. Leave it unset and the instance falls back to the default for whichever provider key it holds, reporting `gpt-5.6-terra` when neither key is configured. The dashboard's picker narrows the same way, offering only the families whose key is present, though an instance with no keys at all still gets the full list so that a fresh install renders a usable form. [Installation](/docs/installation#configuration) lists it alongside every other variable. Anthropic models need `ANTHROPIC_API_KEY` configured and OpenAI models need `OPENAI_API_KEY`; a session naming a model whose key is missing fails at launch with a message saying which variable to set. ## Setting it [#setting-it] ```ts const session = await client.sessions.create({ task: "Capture every open vacancy and return the full schema for each.", model: "gpt-5.6-terra", reasoningEffort: "none", maxCostUsd: 3.0, outputSchema: mySchema, }); ``` `maxCostUsd` is a hard cap checked after every step; [cost control](/docs/cost) covers it, along with `CLOUD_MAX_COST_FACTOR` for callers whose budgets were priced for a hosted service. ## A note on budget models [#a-note-on-budget-models] `gpt-5.6-luna` at `max` was the cheapest full extraction in the benchmark at $0.22, but it took 17 minutes and 36 steps. It is also the most prone to hallucination when the prompt is broad, though the answer-store guards refuse ungrounded values at the boundary either way. Pair it with a tightly scoped prompt and it is genuinely good value; point it at something vague and it will wander. --- # Profiles (/docs/profiles) A profile is a persistent browser storage jar: cookies plus `localStorage` and `sessionStorage` per origin, held on disk in [Playwright storage-state format](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state). Attach one to a session and the agent starts already logged in to whatever that profile was logged in to, instead of hitting a sign-in wall on every run. Profiles are the difference between an agent that can read a public listing and one that can work inside an authenticated application. ## Create a profile [#create-a-profile] ```bash curl -X POST https://your-host/v3/profiles \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "my-profile"}' ``` The response carries the profile `id`; pass it as `profileId` when you create a session. A new profile starts with an empty jar, which the first authenticated session fills. List what exists: ```bash curl https://your-host/v3/profiles \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" ``` Each profile in the response includes its `cookieDomains`, the domains the jar currently holds cookies for, and `lastUsedAt`, which updates whenever a session loads the profile. Rename with `PATCH /v3/profiles/{id}`, delete with `DELETE /v3/profiles/{id}` (which also removes the jar from disk). ## Import from Browser Use Cloud [#import-from-browser-use-cloud] A cloud profile export is the same storage-state shape, cookies plus per-origin `localStorage`, so it imports directly. Import one and **the local profile id matches the cloud id**, so every `profileId` already in your code keeps working. From a source checkout, the import script is the shortest route. It creates the profile if it does not exist, normalises the cookies, and backs up any existing jar to `.import-bak`: ```bash .venv/bin/python -m scripts.import_profiles personal_profile.storage_state.json \ --profile-id --name "Personal Profile" ``` A bundle (a JSON list of profile entries, or `{"profiles": [...]}` wrapping one) carries an id per entry, so one command imports many: ```bash .venv/bin/python -m scripts.import_profiles bundle.json ``` That script ships with the repository rather than with the published package, so an installed copy uses the dashboard's **Profiles** importer or the API below instead. Both do the same work, and the endpoint also creates the profile if the id does not exist yet: ```bash curl -X PUT https://your-host/v3/profiles//storage-state \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" \ -H "Content-Type: application/json" \ --data @personal_profile.storage_state.json ``` Verify with `GET /v3/profiles/`, or the **Profiles** page in the dashboard, which lists the imported `cookieDomains`. ## The storage state format [#the-storage-state-format] ```json { "cookies": [ { "name": "session", "value": "...", "domain": "example.com", "path": "/", "expires": 1999999999, "httpOnly": true, "secure": true, "sameSite": "Lax" } ], "origins": [] } ``` `origins` carries each origin's `localStorage` and `sessionStorage`, which the browser restores on load. When a session ends, the full storage state is written back to the same file, so cookies acquired or refreshed during the run persist, with `localStorage` preserved. The write-back is locked per profile and shielded against shutdown, so two sessions ending at once, or a restart mid-save, cannot truncate a jar. These files are live session credentials. Anyone holding one is logged in as you on every site it covers. Never commit them; a source checkout git-ignores `data/` for this reason, and an installed copy keeps it out of the repository altogether by writing under `~/.openbrowse` instead. Treat a profile export with the same care as a password manager export. ## Using a profile [#using-a-profile] ```ts const session = await client.sessions.create({ task: "Open the billing page and return every invoice as structured data.", model: "claude-sonnet-5", profileId: "", outputSchema: mySchema, }); ``` For credentials the agent must type rather than carry as cookies, use `sensitiveData` alongside the profile; see [writing tasks](/docs/tasks). **One profile, one running session.** Two runs cannot hold the same profile at once, because both would write their cookie jar back over the other's. The claim is taken inside the run rather than at request time, so the second session is accepted by the API, reaches `running`, and only then ends `error` with a message naming the session that holds the profile. If you need two jobs against one login running together, give them separate profiles. ## Listing and organising profiles [#listing-and-organising-profiles] `GET /v3/profiles` returns an envelope rather than a bare array: `{items, totalItems, pageNumber, pageSize}`, with `page` and `pageSize` to walk it and `query` to search by name. A profile also carries an optional `userId`, settable on create and on update, which is there to tag whose login a jar represents when one instance serves several people. Nothing in the runtime reads it; it is yours to filter on. The [API reference](/docs/api) has the full field list for each endpoint. ## Next [#next] [Choosing a model](/docs/models) covers which model and reasoning effort to pair with the work, and [troubleshooting](/docs/troubleshooting) covers what to do when a session fails. --- # Running it on a Raspberry Pi (/docs/raspberry-pi) The thing most people get wrong here is worth settling before you buy anything: OpenBrowse does not run a model on your Pi. The model runs at OpenAI or Anthropic, over the network. What the Pi does is drive Chromium, read what the pages say, and pass that back and forth. That is an orchestration workload rather than an inference one, and the distinction is the whole reason this works. A Pi cannot host a frontier model and never will. It can comfortably run a browser and a Python process, which is all that is being asked of it. Every figure on the [benchmarks page](/benchmarks) was measured on a Raspberry Pi 5 with 16GB of RAM running 64-bit Debian, against a hosted service running in a datacentre. Nothing was scaled down for the occasion, and the Pi was not the slow side of that comparison. ## What you need [#what-you-need] A **Raspberry Pi 5**. It was built and benchmarked on the 16GB model, and the useful way to think about the memory is per session rather than in total: budget roughly 2GB of RAM and one CPU core for each session you want to run at the same time. Chromium itself accounts for 400 to 600MB of that, and the rest covers the pages it loads, the virtual display it draws into, and the Python process. **Storage with room to spare.** The browser build is around 200MB to download and wants roughly 2GB of free disk. On SD-card hardware that download is slow enough to matter, which the server already knows about: it fetches the browser at boot if it is missing, and a session launch waits for a fetch already in flight rather than racing it. Two processes competing for one SD card is the difference between a browser that starts and one that misses its readiness window. **An API key**, from OpenAI or Anthropic. This is the only recurring cost of running OpenBrowse, and it is worth reading [what a run actually costs](/docs/cost) before you pick a model, because the spread between the cheapest and dearest configuration is wider than most people expect. ## How many sessions it will hold [#how-many-sessions-it-will-hold] One, by default. `MAX_CONCURRENT_SESSIONS` raises it, up to a hard ceiling of eight, and the limit that matters in practice is memory rather than anything in the application: two gigabytes and a core each, so a 16GB Pi 5 runs out of room long before it runs out of permission. Past the cap, a session is accepted and queued rather than refused, and the create call returns straight away. Your client does not need to handle a busy signal. ## Three things that are only true on a Pi [#three-things-that-are-only-true-on-a-pi] **The kernel ships with pressure metrics turned off.** Linux can report how hard processes are waiting on CPU, and OpenBrowse uses that to decide whether the machine has room for another session. Raspberry Pi OS compiles the feature in but leaves it disabled, so `openbrowse tune` adds `psi=1` to the boot configuration for you. It takes effect after a reboot, and until then load average is used instead, which is a coarser answer to the same question. **The lighter browser profile is pre-selected.** Chromium's GPU process and its per-site renderer fan-out are worth their overhead on a workstation. Under a virtual display on a small board they cost more than they give back, so setup pre-selects the lighter flags on a Pi, on any machine with four cores or fewer, and on anything with 8GB of memory or less. It stays a checkbox: the recommendation is not a lock. **The system Python refuses to be written to.** Raspberry Pi OS and Debian both mark it as externally managed, so a bare `pip install` is refused by the distribution rather than by us. `uv tool install openbrowse` sidesteps this entirely and is what the [installation guide](/docs/installation) recommends; pipx does the same job if you already have it. ## Installing it [#installing-it] There is nothing Pi-specific about the steps, so they live in one place: follow the [installation guide](/docs/installation). It reads your hardware before it asks you anything, including whether it is running on a Pi and whether the root filesystem is on an SD card, and it sizes the concurrency slider to what the machine can actually hold. Once it is running, [`openbrowse tune`](/docs/cli) applies the two host-level changes above in one idempotent command. --- # Structured output (/docs/structured-output) Pass an `outputSchema` and OpenBrowse does more than validate the final result. The schema is compiled into a validation model before the run, an empty **answer store** in that shape is created, and the agent fills it in place with writes that are each checked live. A value that does not fit is rejected at the moment of writing, with a message the agent can act on, rather than surfacing as a malformed blob at the end. This page covers what the schema may contain, how the store behaves, and why the result you get back does not contain invented data. ## What the schema may contain [#what-the-schema-may-contain] `outputSchema` is a JSON Schema (2020-12) object. The supported subset is what Zod and Pydantic emit in practice: * `object`, `array` and the primitives `string`, `integer`, `number`, `boolean` * Optional fields as `anyOf`/`oneOf` with a `{"type": "null"}` branch, or as a type array `["string", "null"]` * String `enum`s, enforced as literal types * Nested objects and arrays, `$ref` and `$defs`, `additionalProperties` (`false` forbids unknown keys, anything else allows them), and `description` on any field A genuine multi-branch union (more than one non-null type for a field) cannot be represented. In that case the run falls back to prose mode: the schema is appended to the task text, the agent works without the store, and the final answer is validated once at the end, with a single reformat pass if it does not parse. ### Field names carry constraints [#field-names-carry-constraints] String fields gain automatic shape guards from their `format`, or failing that from their name: | Field | Guard | | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `format: "uri"`, `"url"`, or a name ending in `Url`, `Uri`, `Href`, `Link` | Must be an absolute `http(s)` URL. Relative links are rejected with an instruction to resolve them against the page URL | | `format: "email"` or a name ending in `Email` | Must be an email address | | `format: "uuid"` or a name ending in `Uuid` | Must parse as a UUID | | A name ending in `Id` | Must be a single token, no whitespace, at most 128 characters | Values are kept byte-identical rather than normalised, because scraped data should round-trip exactly. Declaring any other explicit `format` opts a field out of the name guards. ### Schema design tips [#schema-design-tips] * Write `description` fields; they are instructions attached to exactly the field they govern, such as `"seniority as stated on the page, null if not stated"`. * Prefer `enum` for categorical fields. Enum writes are evidence-checked (below), which is a stronger guarantee than free text. * Make fields the site might not publish nullable. A required non-nullable field the site never shows can only end in a failed run. * If you want unmapped observables kept rather than dropped, give items a field shaped as a list of `{key, value}` objects (or a plain object field); the draft mapper routes leftovers there. ## How the store behaves during a run [#how-the-store-behaves-during-a-run] The agent writes through a fixed set of actions, and every one validates before it lands: | Action | What it does | | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | `add_item` | Appends one item to the answer array | | `update_item` / `update_items` | Merges fields into one item, or many in one call, reporting per-entry failures without aborting the rest | | `set_field` | Sets a top-level, non-list field | | `remove_items` | Deletes items by index, for duplicates or rows that should never have been records | | `mark_absent` | Settles a field the source genuinely does not publish, with a reason | | `read_output` / `search_output` | Reads back or searches what has been built, windowed and elided so a large store never floods the context | | `add_items_from_file` / `update_items_from_file` | Bulk-loads items or merges from a saved JSON file in one step | The same operations are exposed inside the code sandbox, with identical validation, so a script cannot bypass the store's rules. After every successful write the store is mirrored to `output.json`, and the write's response includes a one-line **coverage summary**: item count, then item fields grouped as filled on all, partial, empty on all, and marked absent. That summary is the agent's verification; it never needs to dump the whole store to know what is missing. ### The guards that stop plausible nonsense [#the-guards-that-stop-plausible-nonsense] * **Evidence-checked enums.** Text from every page read this session accumulates into an evidence corpus. Writing an enum value that no read page states is rejected: `rejected: no read page states it. Enum values must be observed on a page, never inferred or defaulted`. This is aimed directly at the classic failure of filling `seniority: "Senior"` because it is a plausible default. * **The stub throttle.** An item whose detail URL has not been visited and which carries no substantial content is a bare list-row stub. The store holds at most two of those; a third `add_item` is refused with an instruction to read the items' own pages first. This stops a listing page being batch-loaded as the finished answer. * **Earned absence.** `mark_absent` is refused while item pages remain unread, because absence must be observed, not assumed. It is also refused for a field that has a value on some items once every page has been read: a partial field is already complete, and the honest state of the remaining rows is null. * **URL, email, UUID and id guards** apply at the store boundary too, so a malformed link never becomes part of the answer. ## The bulk-read path [#the-bulk-read-path] For list-shaped extractions, the intended golden path takes four steps regardless of how many records there are: 1. `find_links(...)` collects the listing's links with a selector, scrolling the page (and any embedded panel) until the count is stable. It is the only tool that reads links inside a cross-origin embed. 2. `read_pages()` opens every found link in parallel tab waves (up to 48 URLs per call, visible in the live view), waits for each page's real content to render, and saves `{url, title, text, jsonld, links}` per page to `pages.json`. It also **prefills `rows_draft.json`**: one schema row per page, deterministically mapped, the page URL into the item's own-URL field, JSON-LD scalars token-matched onto schema fields (`datePublished` onto `publishedAt`), labelled specs harvested from the visible text, the description HTML-stripped, and every value validated against its field before inclusion. What the page does not state stays empty. 3. `add_items_from_file('rows_draft.json')` loads all rows in one validated step. 4. One `update_items` call fixes the judgement fields, `mark_absent` settles what no page publishes, and `done` finishes. `read_pages` defends the integrity of step 2 aggressively. If several pages come back with near-identical text while cross-origin embeds exist, they were shell reads (the embedding page instead of each page's real content); they are flagged as failures and automatically retried inside the panel. If sibling pages rendered their embedded panel and one did not, that lone fallback is flagged too. A page that fails because the embed never attached is recorded separately from a genuinely dead URL, and never unlocks `mark_absent`. ## The completeness gate [#the-completeness-gate] The first time the agent calls `done` while schema fields are still empty, the call is bounced with the exact list of deficiencies: unset top-level fields, item fields empty on N of M items, an empty answer array, or a **link deficit** (`find_links` captured more usable links than the store holds items, with the unmatched URLs listed). The bounce includes shortcuts when they exist, such as a captured raw key that appears to fill an empty schema field, or a reminder that published dates usually live in each page's JSON-LD rather than its visible text. The gate is one-shot: the second `done` is always accepted, so it can never loop. Fields settled with `mark_absent` do not count as deficiencies, and a field that is partial once every item page has been read is treated as finished. When the gate passes, the feed shows a `schema` event whose action is `completeness` with the final coverage summary. Two consequences worth knowing: * The judge reviews the store's actual content (with long values elided for display, never truncated mid-record), so a complete store cannot be failed for looking abbreviated. * A run that dies before `done` but leaves a complete, schema-valid store is still recorded as a success, with a feed entry saying so. ## Getting the result [#getting-the-result] The final `output` on the session is the store's content, validated once more against the schema; `isTaskSuccessful` reflects both the agent's completion and that validation. ```bash curl http://:8420/v3/sessions/ \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" | jq .output ``` The dashboard's session page can export the same thing (`output` scope), or the full step log with it; see [the live view](/docs/live-view). Fields the site never published come back as null, with the absence recorded deliberately during the run. If you expected values there, check the feed for the `mark_absent` reasons before assuming the run failed; the reason names where the agent looked. --- # Writing tasks (/docs/tasks) Prompt shape changes results more than model choice does. This page explains what the agent actually does with your task text, then gives the patterns that work. ## What happens to your prompt [#what-happens-to-your-prompt] Three things are extracted from the task before the first step: 1. **The start URL.** The first `http(s)` URL in the task is saved to the session clipboard as `startUrl`, and the agent is instructed to begin by opening it. Put the URL in the task; do not make the agent search for the site. 2. **The goal.** A cheap pre-flight LLM call reduces the task to one sentence stating what a complete and correct result looks like. That sentence is pinned under your task as `GOAL:` and re-injected every ten steps, so the first sentence of your task effectively becomes the run's standing instruction. Make it carry the finish line. 3. **The schema, if any.** With an `outputSchema`, an empty answer store is built before the run and the agent is told its purpose is to fill it, not to memorise things. Field names and descriptions in the schema act as instructions; see [structured output](/docs/structured-output). The system prompt already teaches the extraction method (collect links, read pages in bulk, load draft rows, fix judgement fields, settle absent fields, finish), warns the agent that index pages are a table of contents rather than the content, and forbids guessing enum values. You do not need to restate any of that. Your task supplies what the system prompt cannot know: the site, the goal, and the definition of done. ## State the finish line [#state-the-finish-line] The single highest-leverage sentence is a completion target the agent can check itself against. Weak: ``` Get all the jobs from https://example.com/careers ``` Strong: ``` Open https://example.com/careers and capture every currently open vacancy. The page shows a total count; keep going until your number of records matches it. A record is one vacancy with its own detail page. ``` If the page shows a total, say so. If it does not, give the expected order of magnitude ("there are roughly 30 products") or a structural definition ("one record per row in the table"). The completeness gate can verify that every schema field is filled or settled; it cannot know that the site has 14 items unless the page states it or you do. ## Define what a record is [#define-what-a-record-is] Ambiguity about what counts as an item is the most common source of over- or under-counting. Listings often mix records with category links, adverts and "load more" stubs. One sentence settles it: ``` A record is a property listing with a price and its own detail page. Ignore the featured carousel at the top; those repeat further down. ``` ## Say where the truth lives, when you know [#say-where-the-truth-lives-when-you-know] The agent discovers embedded panels, JSON-LD and detail pages on its own, and its `read_pages` tool reads inside cross-origin embeds automatically. But when you already know the structure, saying so saves steps and money: ``` The listings are inside an embedded job board panel. Each vacancy's description and published date are on its own detail page, not the list. ``` ## Keep judgement criteria in the schema, not the prose [#keep-judgement-criteria-in-the-schema-not-the-prose] For structured extraction, put per-field rules in the schema's `description` fields, where they are attached to the exact field they govern, and keep the task about scope and completion. A field description like `"seniority as stated on the page, null if not stated"` outperforms the same rule buried in a paragraph of task text, and the store validates against the field either way. ## Scope tightly for budget models [#scope-tightly-for-budget-models] `gpt-5.6-luna` produces good extractions at very low cost, but only when the prompt is narrow. Broad prompts ("find out everything about this company") invite wandering and, on smaller models, hallucination. If you want cheap, be specific; if you must be vague, use a stronger model. See [choosing a model](/docs/models). ## Secrets, standing instructions and budgets [#secrets-standing-instructions-and-budgets] * **`sensitiveData`** passes credentials as named placeholders: the agent sees the key names and uses them to fill forms, but the values are never shown to the model. ```ts const session = await client.sessions.create({ task: "Log in to https://app.example.com and download this month's invoice data.", model: "claude-sonnet-5", profileId: "", sensitiveData: { app_password: process.env.APP_PASSWORD }, }); ``` * **`systemPromptExtension`** prepends your own standing rules to the built-in ones, and is the right place for policies that apply to every step ("never submit forms", "stay on example.com"). * **`maxCostUsd`** is a hard stop-loss checked after every step; see [cost control](/docs/cost). ## Follow-up tasks [#follow-up-tasks] With `keepAlive: true`, the session returns to `idle` when the task finishes instead of stopping, and a follow-up request with the same `sessionId` runs a new task in it. The browser normally stays open between turns, so a follow-up answers from what the session already knows rather than starting cold. It is not guaranteed to: the idle timeout, an eviction by a newly started run, or a server restart all release the browser while the conversation survives. State that has to outlast a turn belongs in a [profile](/docs/profiles) (cookies, logins) or in the previous task's output, not in open tabs. ## A complete example [#a-complete-example] ```ts const session = await client.sessions.create({ task: [ "Open https://example.com/careers and capture every currently open vacancy.", "The page shows a total; keep going until your record count matches it.", "A record is one vacancy with its own detail page; read each detail page", "rather than the listing snippet.", ].join(" "), model: "gpt-5.6-terra", reasoningEffort: "none", maxCostUsd: 3.0, outputSchema: vacancySchema, }); ``` This is, near enough, the prompt behind the published [benchmark](/benchmarks): 14 of 14 records, $0.24, under two minutes. --- # Troubleshooting (/docs/troubleshooting) The [live view](/docs/live-view) page covers reading the step feed, which diagnoses most failed runs directly. This page covers the rest: the server, the display stack, and the API's error responses. ## Chromium will not start [#chromium-will-not-start] **Symptom:** sessions fail immediately with a Chromium launch error. Almost always a missing shared library. Install the full dependency set: ```bash playwright install-deps ``` Then check that the browser binary actually resolves: ```bash python3 -c "import cloakbrowser; print(cloakbrowser.ensure_binary())" ``` If the path it prints does not exist, reinstall: ```bash pip install --force-reinstall cloakbrowser ``` ## The live view is blank [#the-live-view-is-blank] **Symptom:** the browser panel in the dashboard loads but shows nothing. First, give it a moment. Only the virtual display starts with the session; the VNC server and the websocket bridge spawn on your first request to view it, so the first frame of a view you have just opened arrives a beat after the panel does. If it stays blank, the live view needs three separate processes per session: a virtual display, a VNC server, and a websocket bridge. Confirm all three are installed and on `$PATH`: ```bash which Xvfb x11vnc websockify ``` Install anything missing: ```bash sudo apt install -y xvfb x11vnc novnc websockify ``` Then check they started cleanly: ```bash journalctl -u openbrowse -n 50 ``` ## Authentication errors [#authentication-errors] **`401` with `Server authentication is not configured`:** the instance has no `API_KEY`. Set one in `.env` (or run the `/setup` screen on a fresh install) and restart. **`401` with `Invalid API key`:** the key does not match. The server accepts it either as `Authorization: Bearer ` or in the `X-Browser-Use-API-Key` header; check which one your client sends and that no proxy strips it. **Requests suddenly rejected after repeated failures, with a `429` and a `Retry-After` header:** failed attempts are throttled per client IP with a doubling lockout (five free failures, then 1s doubling up to 15 minutes). Wait, or restart the server to clear the in-memory state, then fix the key rather than retrying it. **The dashboard sends you to `/setup` instead of asking for a password:** the instance has neither a dashboard password nor an API key, so there is nothing yet that a password could be checked against. It answers with a `303` to the setup wizard rather than a challenge, because a challenge on a fresh install is one nothing could answer. Finish the wizard and restart; from then on the dashboard asks for the credentials you chose. No default password ships, and this is why. A known pair would be a working credential during exactly the window before anyone has chosen one, on a service holding provider API keys and imported profiles with live logged-in cookies. The v3 API is deliberately not part of this: a programmatic call still fails closed with a `401` rather than being redirected to an HTML wizard. ## The API refuses my request [#the-api-refuses-my-request] **`422` with `Session is running, not idle`:** you sent a follow-up task (`sessionId` set) to a session that has not finished. Poll until it is `idle`, or stop it first with strategy `task`. A `keepAlive` session is exempt from the status rule, because it is a conversation rather than a one-shot run, but it is still refused while it is genuinely mid-task. **`422` with `Task is required when targeting an existing session`:** a follow-up request must carry a `task`. **`422` naming `reasoningEffort`:** the value is not valid for that model; the error lists the accepted set. The per-model table is in [choosing a model](/docs/models). The same applies to the retired `thinkingEffort` and `modelThinkingEffort` fields, and to sending both `thinkingLevel` and `reasoningEffort` at once. **`'x' is not a valid model`:** the model name is not in the supported list, also in [choosing a model](/docs/models). **A session goes straight to `error` with `Profile is in use by running session `:** two runs cannot hold the same browser profile at once. The claim is taken inside the run rather than at request time, so the second session is accepted and reaches `running` before it fails. Wait for the holder to finish, or give the second run its own profile. **Session errors at launch with `needs OPENAI_API_KEY` (or `ANTHROPIC_API_KEY`):** the model's provider key is missing from `.env`. ## A session stopped before finishing [#a-session-stopped-before-finishing] Read `failureKind` on the session first. It says in one word whether the failure was yours, the site's, or the model provider's, which decides whether retrying the identical request is worth anything: | `failureKind` | Retrying the same request | | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provider_rate_limit`, `provider_server_error`, `provider_connection_error`, `provider_timeout` | Worth it. Nothing about your task or the site caused this; `failureStatusCode` carries the provider's status | | `session_timeout` | Only on a quieter host, or with a tighter task | | `budget_exceeded` | Only with a higher cap; see [cost control](/docs/cost) | | `invalid_output` | Not as-is. The model's reply was truncated or unusable; a simpler schema usually fixes it | | `agent_failure` | Not as-is. Either the agent gave up, or the run never started at all: a missing provider key lands here too, before a single step runs. Read the feed to tell which | | null, on a session that failed | Not classified. A server restart or a profile another session already holds fails outside the classified path; `lastStepSummary` and the feed are the source of truth | `failureKind` is not the verdict on its own. A run stopped by its cost cap that salvaged a complete, valid answer store is recorded successful and still carries `budget_exceeded`, so read `isTaskSuccessful` alongside it. Then check the last feed entry, or the `lastStepSummary` on the session. All four below reach `lastStepSummary`; the cost stop and the step timeout are additionally written into the feed as rows, so you will see those either way: * **`Stopped: Cost $X exceeded budget $Y`:** the `maxCostUsd` cap fired. Whatever the answer store held at that point is preserved in `output`, and the run is recorded a success only if that output validates and passes the completeness gate, so a partial result is visible rather than lost. The session goes `stopped`, or `idle` if it was created with `keepAlive`. Raise the cap, or reduce spend; see [cost control](/docs/cost). * **`Interrupted by server restart`:** the server went down mid-run. The session is marked `error` at the next startup because it can never resume; run the task again. * **Status `expired`:** the session was created without a task and never given one; task-less `created` sessions are expired after 15 minutes. Create a new one. * **`Step timed out and was cancelled before completing`:** one step exceeded the 520-second ceiling; the run continues past it, but repeated timeouts usually mean the host is overloaded. ## Out of memory [#out-of-memory] **Symptom:** sessions are killed mid-task, the machine becomes unresponsive, or the OOM killer fires. Each Chromium instance uses roughly 400 to 600MB. Budget 2GB per concurrent session to leave room for the pages it loads, the virtual display and the Python process, so concurrency is bounded by RAM rather than by anything in the application. ```bash free -h dmesg | grep -i oom ``` Reduce concurrency in `.env` and restart: ```bash MAX_CONCURRENT_SESSIONS=1 ``` The default is 1; if sessions are being killed at that setting, something else on the box is taking the memory. Before reaching for swap, try the lighter browser profile, which lowers the per-session memory floor and is close to free on a machine with no real GPU: ```bash CHROME_LIGHT_FLAGS=1 ``` `openbrowse tune --share most` additionally caps the service's memory through systemd, so a runaway session is bounded rather than taking the machine down with it. Both are covered under [sizing it for your machine](/docs/installation#sizing-it-for-your-machine). If you would rather trade speed for headroom, add swap: ```bash sudo dphys-swapfile swapoff sudo nano /etc/dphys-swapfile # set CONF_SWAPSIZE=4096 sudo dphys-swapfile setup sudo dphys-swapfile swapon ``` Swap on an SD card is slow and will wear it. On a Raspberry Pi, prefer an SSD or lower concurrency. Related: the server samples host CPU pressure and posts a `system` warning into a run's feed when it launches under saturation, because an overloaded host misses the timing windows in which embedded panels attach. If a run's failures coincide with that warning, re-run when the box is quiet before concluding the site changed. Where the kernel exposes pressure stall information that reading is stall time rather than load average, which is markedly the better signal; on a Raspberry Pi PSI is compiled out by default and `host_tune.sh` adds the boot flag that enables it. ## The dashboard's tuning or restart buttons do nothing [#the-dashboards-tuning-or-restart-buttons-do-nothing] **Symptom:** a machine that was tuned once no longer responds to the Settings page's tuning button, or a restart from the dashboard drops the process instead of restarting the service cleanly. Both of those work through a sudoers entry, and that entry names the tuning script by its full path. An upgrade moves the package, so the path changes and the grant stops matching. The Settings page detects the mismatch and names the command, but the fix is the same either way: ```bash openbrowse tune --share most ``` Run it after every upgrade. It rewrites the grant for the new path, covering both the tuning script and the `systemctl restart` the dashboard's restart button needs. ## An update will not install [#an-update-will-not-install] **Symptom:** the Settings page reports the update as failed, or `openbrowse update` exits non-zero. If it refused rather than failed, a session was running: the install is deliberately blocked while any browser is live. Wait for the session to finish, or stop it, and try again. Otherwise the upgrade command itself failed, and which command that was depends on which manager owns this copy: `uv tool upgrade openbrowse`, `pipx upgrade openbrowse`, `pip install --upgrade openbrowse` inside its virtual environment, or `git pull --ff-only` for a checkout. Running it by hand shows you the real error, which for a checkout is usually a local commit or a dirty tree that a fast-forward cannot pass. Restart afterwards. If instead it names an install method but offers no command, the manager that owns this copy is not on the server's `PATH`. That is common under systemd, which hands the service a minimal `PATH` that often omits `~/.local/bin`, where both uv and pipx install themselves. Upgrade from a shell instead. The same message appears for a copy in the system Python, where no unattended upgrade is safe. If the badge never appears at all, `UPDATE_CHECK_HOURS` may be `0`, which switches the background check off. `openbrowse check-update` asks immediately regardless. ## Tailscale Funnel is not answering [#tailscale-funnel-is-not-answering] **Symptom:** the public HTTPS URL returns a connection error or a 502. Check the funnel is actually up: ```bash tailscale funnel status ``` If that is empty, bring it back: ```bash sudo tailscale funnel --bg 8420 ``` Confirm the server is listening on the port the funnel points at: ```bash ss -tlnp | grep 8420 ``` And check the obvious one: Funnel is HTTPS only. An `http://` URL will not work. ## An extraction returned fewer records than the page shows [#an-extraction-returned-fewer-records-than-the-page-shows] This is usually correct behaviour rather than a bug. Values without on-page evidence are refused at the answer-store boundary, and a field the source genuinely never displays is settled as absent instead of guessed, with the reason recorded in the feed; see [structured output](/docs/structured-output). If you are confident the data is on the page, the usual causes, in order: * **The listing is inside a cross-origin iframe that never attached.** This is handled automatically, but check the feed for a `find_links` entry reporting `0 link(s) matched` or a frame filter matching 0 frames, and for `read_pages` shell-read retries that did not recover. * **The content is behind unusual lazy loading.** The link collector scrolls until the count is stable, but a page with a non-scroll loading trigger (a button, a filter) may need the prompt to say so explicitly. * **The prompt did not state a completion target.** If the page shows a total, say so: "keep going until your count matches the displayed total" is a materially better instruction than "get all of them". See [writing tasks](/docs/tasks). Export the run (`full` scope) from the dashboard when you want to attach it to an issue; it contains every action, error and reviewer note. --- # v3 API reference (/docs/api) OpenBrowse serves the same v3 REST surface as Browser Use Cloud, so an existing `browser-use-sdk` client works against it once you change `baseUrl` and `apiKey`. These 13 operations are generated directly from the application's own OpenAPI schema, so they cannot drift from what your instance actually serves. ## Base URL and authentication [#base-url-and-authentication] Every request goes to your own instance, under `/v3`, and carries your bearer token: ```bash curl https://your-host/v3/sessions \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" ``` The token is the `API_KEY` value written to `.env` during [installation](/docs/installation). There is no separate account system and no per-task billing: your instance answers to whoever holds that token, which is why [exposing it safely](/docs/exposing) matters. ## Profiles [#profiles] A profile is a persistent browser storage jar, cookies plus localStorage per origin. Attach one to a session and the agent starts already signed in. | Operation | Method | Path | | ----------------------------------------------------------------------- | -------- | ----------------------------------------- | | [List Profiles](/docs/api/v3/profiles/get) | `GET` | `/v3/profiles` | | [Create Profile](/docs/api/v3/profiles/post) | `POST` | `/v3/profiles` | | [Get Profile](/docs/api/v3/profiles/profile_id/get) | `GET` | `/v3/profiles/{profile_id}` | | [Update Profile](/docs/api/v3/profiles/profile_id/put) | `PUT` | `/v3/profiles/{profile_id}` | | [Update Profile](/docs/api/v3/profiles/profile_id/patch) | `PATCH` | `/v3/profiles/{profile_id}` | | [Delete Profile](/docs/api/v3/profiles/profile_id/delete) | `DELETE` | `/v3/profiles/{profile_id}` | | [Put Storage State](/docs/api/v3/profiles/profile_id/storage-state/put) | `PUT` | `/v3/profiles/{profile_id}/storage-state` | ## Sessions [#sessions] A session is one agent run. Create one with a task, a model and an output schema, then poll it or read its message stream while it works. | Operation | Method | Path | | -------------------------------------------------------------- | -------- | ------------------------------------ | | [List Sessions](/docs/api/v3/sessions/get) | `GET` | `/v3/sessions` | | [Create Session](/docs/api/v3/sessions/post) | `POST` | `/v3/sessions` | | [Get Session](/docs/api/v3/sessions/session_id/get) | `GET` | `/v3/sessions/{session_id}` | | [Delete Session](/docs/api/v3/sessions/session_id/delete) | `DELETE` | `/v3/sessions/{session_id}` | | [List Messages](/docs/api/v3/sessions/session_id/messages/get) | `GET` | `/v3/sessions/{session_id}/messages` | | [Stop Session](/docs/api/v3/sessions/session_id/stop/post) | `POST` | `/v3/sessions/{session_id}/stop` | ## Errors [#errors] Validation failures return `422` with a body describing which field failed and why. Anything that would exceed a session's `maxCostUsd` cap stops the run rather than continuing to spend. --- # List Profiles (/docs/api/v3/profiles/get) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} List Profiles. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X GET "https://your-host/v3/profiles" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" ``` --- # Create Profile (/docs/api/v3/profiles/post) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} Create Profile. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X POST "https://your-host/v3/profiles" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` --- # List Sessions (/docs/api/v3/sessions/get) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} List Sessions. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X GET "https://your-host/v3/sessions" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" ``` --- # Create Session (/docs/api/v3/sessions/post) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} Create Session. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X POST "https://your-host/v3/sessions" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` --- # Delete Profile (/docs/api/v3/profiles/profile_id/delete) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} Delete Profile. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X DELETE "https://your-host/v3/profiles/{profile_id}" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" ``` --- # Get Profile (/docs/api/v3/profiles/profile_id/get) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} Get Profile. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X GET "https://your-host/v3/profiles/{profile_id}" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" ``` --- # Update Profile (/docs/api/v3/profiles/profile_id/patch) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} Update Profile. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X PATCH "https://your-host/v3/profiles/{profile_id}" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` --- # Update Profile (/docs/api/v3/profiles/profile_id/put) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} Update Profile. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X PUT "https://your-host/v3/profiles/{profile_id}" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` --- # Delete Session (/docs/api/v3/sessions/session_id/delete) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} Delete Session. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X DELETE "https://your-host/v3/sessions/{session_id}" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" ``` --- # Get Session (/docs/api/v3/sessions/session_id/get) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} Get Session. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X GET "https://your-host/v3/sessions/{session_id}" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" ``` --- # Put Storage State (/docs/api/v3/profiles/profile_id/storage-state/put) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} Put Storage State. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X PUT "https://your-host/v3/profiles/{profile_id}/storage-state" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` --- # List Messages (/docs/api/v3/sessions/session_id/messages/get) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} List Messages. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X GET "https://your-host/v3/sessions/{session_id}/messages" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" ``` --- # Stop Session (/docs/api/v3/sessions/session_id/stop/post) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} Stop Session. Send this to your own instance, replacing `your-host` with the hostname it answers on. ```bash curl -X POST "https://your-host/v3/sessions/{session_id}/stop" \ -H "Authorization: Bearer $OPENBROWSE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ```