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) -> strsetsasync=Trueon the payload and returns the server'sjob_id. It works for any tool, not justbrowser_nla. It raisesOwlBrowserErrorif 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 raisesOwlBrowserError, it does not hang or returnNone.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. RaisesOwlBrowserErroronfailed, oncancelled, 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 intostate.suppliedValues;textstays 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_reportedandexpiredare inert by default. Tune withmin_confidence/goal_threshold. - Page it when it is big. A choice question allows 255 options, so the
browser caps candidates at 254 +
none. Useobs.has_more/obs.next_offsetwithcandidate_offset, or narrow withregion=/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)
| File | Size | Uploaded | |
|---|---|---|---|
| owl_browser-2.2.0.tar.gz | 165.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|