Skip to main content

Owl Browser Python SDK

Async Python client for the Owl Browser HTTP server: one method per browser tool, generated from the bundled OpenAPI schema.

Install

pip install owl-browser

Python 3.12+. Runtime dependencies: aiohttp, pyjwt[crypto], cryptography, beautifulsoup4.

Quick start

import asyncio
from owl_browser import OwlBrowser, RemoteConfig

async def main():
    config = RemoteConfig(
        url="http://localhost:8080",
        token="your-token",
        api_prefix="",  # direct to http-server; keep the default "/api" behind nginx
    )

    async with OwlBrowser(config) as browser:
        ctx = await browser.create_context()
        await browser.navigate(context_id=ctx, url="https://example.com", wait_until="load")

        text = await browser.extract_text(context_id=ctx)
        print(text)

        await browser.close_context(context_id=ctx)

asyncio.run(main())

async with calls connect() on entry and close() on exit. Without it, call them yourself.

create_context() returns the context id as a str. Every other tool takes context_id as a keyword argument.

Configuration

RemoteConfig is a dataclass in owl_browser.types (re-exported from owl_browser).

Field Type Default Meaning
url str required Server base URL. A trailing / is stripped.
token str | None None Bearer token. Required when auth_mode is TOKEN.
auth_mode AuthMode AuthMode.TOKEN TOKEN or JWT.
jwt JWTConfig | None None Required when auth_mode is JWT.
transport TransportMode TransportMode.HTTP HTTP or WEBSOCKET.
timeout float 30.0 Per-request timeout in seconds.
max_concurrent int 10 Concurrent in-flight requests (semaphore and aiohttp connector limit).
retry RetryConfig RetryConfig() Retry policy, HTTP transport only.
verify_ssl bool True TLS certificate verification.
api_prefix str "/api" Path prefix for every request. Normalised to a leading / with no trailing /.

__post_init__ raises ValueError if auth_mode is TOKEN with no token, or JWT with no jwt.

Paths are built as {url}{api_prefix}/execute/{tool}, so use the default "/api" behind the nginx proxy and api_prefix="" when talking to the http-server directly on port 8080.

RetryConfig: max_retries=3 (total attempts, not extra attempts), initial_delay_ms=100, max_delay_ms=10000, backoff_multiplier=2.0, jitter_factor=0.1.

from owl_browser import RemoteConfig, RetryConfig

config = RemoteConfig(
    url="https://your-host",
    token="your-token",
    timeout=30.0,
    max_concurrent=10,
    retry=RetryConfig(max_retries=5, initial_delay_ms=200),
)

JWT (RS256, key read from disk, refreshed automatically before expiry):

from owl_browser import RemoteConfig, AuthMode, JWTConfig

config = RemoteConfig(
    url="https://your-host",
    auth_mode=AuthMode.JWT,
    jwt=JWTConfig(
        private_key_path="/path/to/private.pem",
        expires_in=3600,
        refresh_threshold=300,
        issuer="my-app",
        subject="user-123",
    ),
)

timeout applies per request, with one exception: over the HTTP transport a fixed set of slow tools (navigation, waits, content extraction, screenshots, CAPTCHA and the AI tools) uses max(120.0, timeout * 4) instead. Anything slower than that belongs in a job, see below.

Calling tools

execute() runs any tool by its server name:

result = await browser.execute(
    "browser_navigate",
    context_id=ctx,
    url="https://example.com",
)

tool_name is positional-only, so tools that themselves take a tool_name parameter work:

await browser.execute(
    "browser_webmcp_call_tool",
    context_id=ctx,
    tool_name="echo",
    input='{"message":"hi"}',
)

At construction the client reads the bundled OpenAPI schema and builds one async method per tool, named without the browser_ prefix. These are equivalent:

await browser.execute("browser_click", context_id=ctx, selector="#submit")
await browser.click(context_id=ctx, selector="#submit")

Tools whose names do not start with browser_ (for example http_request, server_read_logs) keep their full name as the method name. All parameters are keyword arguments. Fields the schema declares as integers are coerced from float to int before the request is sent.

create_context is the one name that is not dynamic: it is a real method that unwraps the response and returns the context id string, raising OwlBrowserError if the server returns anything else.

Introspection is synchronous and offline (it reads the bundled schema, not the server):

browser.list_tools()        # ['browser_navigate', 'browser_click', ...] server tool names
browser.list_methods()      # ['navigate', 'click', ...] generated method names
browser.has_method("click") # True
browser.get_tool("browser_click")  # ToolDefinition | None

ToolDefinition carries name, description, parameters (a dict of ParameterDef), required_params and integer_fields.

Two more client methods: health_check() (HTTP transport only) and the sync wrappers connect_sync(), execute_sync(), close_sync(). Each sync wrapper calls asyncio.run() on a fresh event loop, so they do not share a connection; prefer the async API.

Long running work

An agentic run (browser_nla) routinely takes minutes and will blow past the server's request timeout if you run it synchronously. The connection is dropped while the browser keeps working and the result is lost. Submit it as a background job instead.

run_task

This is the one to use. One call: it submits the job, polls it, and returns the unwrapped answer.

answer = await browser.run_task(ctx, "Find the cheapest book and report its price")
run_task(context_id, command, *, timeout=600.0, poll_interval=2.0, **params) -> Any

timeout and poll_interval are in seconds. Extra keyword arguments are passed through to browser_nla. It raises OwlBrowserError if the job fails, is cancelled, or does not finish within timeout (the job keeps running server-side in the timeout case).

Worked example. browser_nla needs an LLM on the context, either the built-in model or a third-party endpoint:

import asyncio
from owl_browser import OwlBrowser, RemoteConfig

URL = "https://books.toscrape.com/catalogue/category/books/travel_2/index.html"

async def main():
    config = RemoteConfig(url="http://localhost:8080", token="your-token", api_prefix="")

    async with OwlBrowser(config) as browser:
        ctx = await browser.create_context(
            render_mode="agent",
            llm_use_builtin=False,
            llm_is_third_party=True,
            llm_endpoint="http://192.168.1.197:1234",
            llm_model="qwen/qwen3.8-27b",
            llm_api_key="lmstudio",
        )
        await browser.navigate(context_id=ctx, url=URL, wait_until="load")

        answer = await browser.run_task(
            ctx,
            "Open the product page for 'The Great Railway Bazaar' and report how "
            "many copies are in stock. Answer with just the number.",
        )
        print(answer)  # -> the answer payload, envelopes already stripped

        await browser.close_context(context_id=ctx)

asyncio.run(main())

The job API

Use these when you want the job id itself: to run several tasks at once, to report progress, or to cancel.

job_id = await browser.submit_job("browser_nla", context_id=ctx, command="...")

job = await browser.get_job(job_id)     # dict for one job
progress = await browser.get_task_status(ctx)  # phase, step, plan, last_event
jobs = await browser.list_jobs()        # list of dicts, no result payloads
await browser.cancel_job(job_id)        # dict, the job as it stands after the request

result = await browser.wait_for_job(job_id, timeout=600.0, poll_interval=2.0)
  • submit_job(tool_name, /, **params) -> str sets async=True on the payload and returns the server's job_id. It works for any tool, not just browser_nla. It raises OwlBrowserError if the server does not return a job id.
  • get_job(job_id) returns {"job_id", "state", "tool", "created_ms", "updated_ms"} plus "result" and "error" once they exist. An unknown id raises OwlBrowserError, it does not hang or return None.
  • list_jobs() returns the live jobs, newest first, without their result payloads.
  • cancel_job(job_id) requests cancellation, see the state notes below.
  • wait_for_job(job_id, *, timeout=600.0, poll_interval=2.0) polls until the job leaves the non-terminal states, then returns the unwrapped result. Raises OwlBrowserError on failed, on cancelled, and on its own timeout.

run_task is exactly submit_job("browser_nla", ...) followed by wait_for_job(...).

Job states

queued, running, done, failed, cancelled, and cancelling.

Cancellation is cooperative. A queued job cancels immediately. For NLA jobs, cancel_job also signals the browser-side loop, which exits between native actions or LLM calls. An in-flight network request is allowed to return safely. Use get_task_status(ctx) for live phase, step, plan, and last-action progress.

Results

Job results are stored as the raw tool response, which means a REST envelope and, for browser_nla, a JSON string inside it. run_task and wait_for_job peel those layers off and hand back the payload. Reading job["result"] from get_job() yourself gives you the unpeeled string.

Requirements

Async jobs are plain REST endpoints under /jobs and exist only on the HTTP transport. A client built with transport=TransportMode.WEBSOCKET raises OwlBrowserError telling you so.

Jobs are held in memory per server process. They do not survive a server restart, and neither does the browser context they were driving.

System One models

A System One model — TypeSafe's Jev family is the first — is not a generative LLM. It is a constrained-decision evaluator: you give it free-form state plus named typed questions, and it returns one of the option keys you supplied, with a probability distribution. It cannot invent a selector, a coordinate, free text, or a tool call.

So the normal tool surface does not fit it: browser.click(selector=...) assumes the caller can produce a selector. browser.sys1 translates instead — it turns the page into a closed set of fully-formed actions, and maps the model's chosen key back to a real tool call.

Neither the browser nor this SDK ever contacts a model provider. You get a ready-to-POST request body, you call your provider with your own key, you hand the answers back. Your credentials never reach the browser.

The loop

from owl_browser import OwlBrowser, RemoteConfig, Sys1Value

async with OwlBrowser(RemoteConfig(url="http://localhost:8080", token=TOKEN)) as browser:
    ctx = await browser.create_context(render_mode="agent")
    await browser.navigate(context_id=ctx, url="https://example.com/signup")

    # 1. Page -> closed option set + a request body your provider accepts
    obs = await browser.sys1.observe(
        ctx,
        goal="Fill in the email address field",
        values=[Sys1Value(id="email", text="ada@example.com",
                          description="the user's email address")],
    )

    # 2. Your provider call. obs.request is forwarded verbatim.
    answers = my_provider(obs.request)

    # 3. Resolve against the snapshot. This NEVER executes.
    decision = await browser.sys1.answer(obs.snapshot_id, answers)

    # 4. Execute only if you want to.
    if decision.is_actionable:
        result = await browser.sys1.execute(obs.snapshot_id, decision.candidate_id)

step() does the same four calls for you — the provider call still happens entirely inside your ask:

step = await browser.sys1.step(
    ctx,
    goal="Fill in the email address field",
    values=[Sys1Value(id="email", text="ada@example.com",
                      description="the user's email address")],
    ask=my_provider,             # sync or async; receives the request body
    auto_execute=True,           # False to gate on step.decision yourself
)
print(step.decision.status, step.execution.description)

What the model sees

observe() returns candidates that are each a complete, executable call:

c0  click   Click link "Terms of Service"
c2  type    Fill textField "Email Address *" with supplied value "email": the user's email address
c7  scroll  Scroll to the bottom of the page

Plus a mandatory none ("no listed action is suitable"), so the model is never forced to pick the least-bad option.

A type candidate exists only for a (field, value) pair — a type is not executable until it is paired with a specific value, so pass values if you want the model to be able to fill anything. Without them the page yields clicks only.

Things worth knowing

  • Supplied values never reach the model. Only {id, description} goes into state.suppliedValues; text stays in the browser's binding and is resolved at execute time. A password or token is never sent to the provider.
  • Bindings never leave the browser. The model sees {id, description}; the tool and its arguments stay server-side. A model cannot name an action that was not offered — that, not response arithmetic, is the safety boundary.
  • Snapshots are single-use and expire (120 s). The binding is consumed before the side effect, so a replayed answer cannot fire twice.
  • Deciding is separate from executing. Only status == "proposed" is actionable; needs_review, no_action, goal_reported and expired are inert by default. Tune with min_confidence / goal_threshold.
  • Page it when it is big. A choice question allows 255 options, so the browser caps candidates at 254 + none. Use obs.has_more / obs.next_offset with candidate_offset, or narrow with region= / detail="min".

Answer shape

answers is whatever your provider returned. answer() also accepts the whole response body and will pick out .answers for you. Expected shape:

{
  "action":       { "type": "choice", "choice": "c2", "confidence": 0.93,
                    "probabilities": { "c2": 0.93, "none": 0.07 } },
  "goal_visible": { "type": "noul", "noul": 0.05 }
}

Parsing is deliberately lenient: only a choice that the snapshot offered is required. A partial distribution, one that does not sum to 1, or a choice that is not the argmax are all accepted — with up to 255 options a truncated top-k is plausible, and rejecting it would break execution for no safety gain.

Errors

Every exception derives from OwlBrowserError, importable from owl_browser.

Exception Raised when Extra attributes
OwlBrowserError Base class. Also raised directly for job failure, cancellation and polling timeouts, for job calls on a WebSocket client, for health_check() on a WebSocket client, for a bad create_context response, and for non-2xx replies from /jobs.
ConnectionError Transport cannot connect, retries are exhausted, or the WebSocket is closed or not connected. message, cause
AuthenticationError HTTP 401, or a WebSocket handshake rejected with 401. message, reason, status_code
IPBlockedError HTTP 403. message, ip_address, status_code
RateLimitError HTTP 429. message, retry_after, limit, remaining, status_code
ToolExecutionError Any other HTTP status at or above 400, or a 2xx envelope with success: false. tool_name, message, status, result
TimeoutError The request exceeds the transport timeout. message, timeout_ms
OpenAPISchemaError The bundled schema is missing or unparseable. message, cause

ConnectionError and TimeoutError shadow the Python builtins of the same name. Import them explicitly, or alias them, if you also catch the builtins.

from owl_browser import (
    AuthenticationError,
    ConnectionError,
    OwlBrowserError,
    RateLimitError,
    TimeoutError,
    ToolExecutionError,
)

async def click(browser, ctx):
    try:
        await browser.click(context_id=ctx, selector="#missing")
    except ToolExecutionError as e:
        print(f"{e.tool_name} failed: {e.message}")
    except RateLimitError as e:
        print(f"rate limited, retry after {e.retry_after}s")
    except (AuthenticationError, ConnectionError, TimeoutError) as e:
        print(f"transport problem: {e}")
    except OwlBrowserError as e:
        print(f"other SDK error: {e}")

401, 403 and 429 are never retried. Other connection and response errors are retried per RetryConfig.

owl_browser.exceptions also defines ElementNotFoundError, NavigationError, ContextLimitError, ExpectationError and FlowExecutionError. Apart from FlowExecutionError (used by the flow executor), the SDK does not raise these on its own: they come from the raise_for_action_result(result) helper in the same module, which you call on a tool result when you want a failed action to become an exception.

Transports

Default is HTTP. Select the WebSocket transport on the config:

from owl_browser import RemoteConfig, TransportMode

config = RemoteConfig(
    url="http://localhost:8080",
    token="your-token",
    transport=TransportMode.WEBSOCKET,
)

Both transports expose the same execute() and the same generated methods. What differs:

HTTP WebSocket
Endpoint POST {api_prefix}/execute/{tool} one connection to {api_prefix}/ws, requests correlated by integer id
Retries per RetryConfig none
Timeout timeout, raised to max(120.0, timeout * 4) for known slow tools timeout for every call, no exception list
Concurrency limited by max_concurrent multiplexed over the single connection, not limited by max_concurrent
health_check() supported raises OwlBrowserError
Async jobs supported raises OwlBrowserError
Non-JSON responses returned as text not applicable, frames are JSON

Use HTTP unless you specifically need a persistent connection. It is the only transport with retries, extended timeouts for slow tools, and the job API.

Also in the package

Three optional layers ship alongside the tool client. Each is independent, and none of them is needed for the sections above.

Import What it is
from owl_browser import FlowExecutor Runs declarative JSON flows: steps, captured variables, for_each, retries, expectations. See docs/FLOW_EXECUTOR.md.
from owl_browser import Extractor Field specs to pull structured records out of a page.
from owl_browser.playwright import chromium A Playwright-shaped facade (Browser, BrowserContext, Page) over the same client, for porting existing scripts.

Release files for owl-browser 2.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for owl-browser 2.2.0
File Size Uploaded
owl_browser-2.2.0.tar.gz 165.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for owl-browser 2.2.0
File Interpreter ABI Platform
owl_browser-2.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 353.9 kB

Release files / owl_browser-2.2.0.tar.gz

Download URL owl_browser-2.2.0.tar.gz
Size 165.0 kB
Tags Source
SHA-256 checksum
How to use checksums
417b0607fe3f994b76583c75a3d15ca3c4d08518737f4f497faea19fe48197ab
BLAKE2b-256 checksum
How to use checksums
b01fb009eed8c7db1e7c66ddf745cc6608521a5bd963d295c40928851cce1624
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.6

Release files / owl_browser-2.2.0-py3-none-any.whl

Download URL owl_browser-2.2.0-py3-none-any.whl
Size 188.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9a0e8cbd79ba28f25e584d62d33e95ecddd941eeb27245bd1ec586398bbbc135
BLAKE2b-256 checksum
How to use checksums
10d2562184ad092dc2ba33a8913b5a18a0c49a32a1cd19274e8cde836aa38c15
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.6

Release history Release notifications | RSS feed

This release

2.2.0 This release

2 release files

2.1.6

2 release files

2.1.5

2 release files

2.1.4

2 release files

2.1.3

2 release files

2.1.2

2 release files

2.1.1

2 release files

2.1.0

2 release files

2.0.9

2 release files

2.0.7

2 release files

2.0.6

2 release files

2.0.5

2 release files

2.0.4

2 release files

2.0.3

2 release files

2.0.2

2 release files

2.0.1

2 release files

2.0.0

2 release files

1.2.6

2 release files

1.2.5

2 release files

1.2.4

2 release files

1.2.3

2 release files

1.2.2

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page