# HTTP API (/docs/api) import { Callout } from "fumadocs-ui/components/callout"; Bowmark is an HTTP API behind an API key. **Nothing needs to be installed and no MCP client is required** — if your code can send a request, it can use Bowmark. This page is the reference: the base URL, the header, the endpoints. For worked examples in Node, Python and `curl`, go to [Scripting § No install at all](/docs/scripting#no-install-at-all). ## Base URL and auth ``` https://api.bowmark.ai ``` **Not `bowmark.ai`** — that host serves the marketing site and these docs, and will 404 an API path. Every endpoint below takes the same header: ``` Authorization: Bearer $BOWMARK_API_KEY ``` There is no anonymous tier and no public browsing: every call — `GET /v1/library` included — is refused with **401** without a key. Sign up at [bowmark.ai/sign-up](https://bowmark.ai/sign-up), create a key at [bowmark.ai/dashboard/keys](https://bowmark.ai/dashboard/keys). The first $10 of usage a month is free with no card; add one at [bowmark.ai/dashboard/billing](https://bowmark.ai/dashboard/billing) to go past it. ## The two calls Everything a caller does is one of these, in this order — read the library, then send a script written against it. | | | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/library?query=…` | **Read the vocabulary.** Returns the typed function library for a task or a site. Read-only, touches no site, costs nothing, and an unrecognized query returns the whole index rather than an error — so the call cannot dead-end. | | `POST /v1/run` | **Execute a script** written against that library, on the live sites. Body is `{"script":"…"}`; the script is async JavaScript and `return`s its result. | ```sh curl -sS "https://api.bowmark.ai/v1/library?query=flights" \ -H "Authorization: Bearer $BOWMARK_API_KEY" ``` ```sh curl -sS -X POST https://api.bowmark.ai/v1/run \ -H "Authorization: Bearer $BOWMARK_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"script":"const { offers } = await bowmark.pcparts.search(\"RTX 5080\"); return offers.slice(0,3);"}' ``` `run` answers `{ ok, result, logs, error, ms }`. What you may write inside `script` — the `bowmark.*` globals, `log`, `talk`, `pay`, and the `read.page` fallback for a site with no provider — is [Scripting](/docs/scripting). It is a document meant to go straight into a model's context, so the default response is `text/markdown`. Send `Accept: application/json` to get the machine shape instead. ## Generating types | | | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/manifest?units=…` | The same library as a **machine-readable manifest**, for a code generator rather than a model. `units` is required and comma-separated (`?units=flights,providers.kayak`) — there is deliberately no whole-catalog form, so call `/v1/library` first to find the ids. | You usually do not need this: the [`@bowmark/web` and `bowmark-web` packages](/docs/scripting#from-your-own-code) are generated from it and ship the types already. ## What is not here The routes above are the whole caller-facing surface. Everything else under `/v1/` — the login handoff, secrets, files, saved connections, the per-company MCP servers — is machinery the SDK and the sandbox drive on your behalf, documented where it is used rather than as a route list. If you are reaching for one of those directly, email [support@bowmark.ai](mailto:support@bowmark.ai) and tell us what you are building; that is a gap in this page, not in the API. ## Where to go next * **[Quickstart](/docs/quickstart)** — one `curl`, a real answer off three live retailers. * **[Scripting § No install at all](/docs/scripting#no-install-at-all)** — the same two endpoints with worked Node, Python and `curl` examples, and the Python `urllib` User-Agent caveat that costs a **403** if you miss it. * **[Installation](/docs/installation)** — if your client *does* speak MCP, wire it up once and skip the HTTP layer entirely. # Evals (/docs/evals) Every run below actually happened. The prompt is what a person typed, the calls are what the model wrote against the library, and the answer is what came back off the live sites. Nothing is hand-written and **no prompt names a Bowmark function** — the model gets one lookup and has to find its own way there. They all ran on **GPT-5.6 Luna**, the cheapest tier OpenAI publishes, at $0.20 per million input tokens. That is deliberate. The hard part of these tasks is the website, not the model: the agent is calling a typed function, so it never has to be clever enough to drive a browser, read a rendered page, or recover from a layout change. Every prompt was run five times. **The pass count on each row is the real one** — ten of these went five for five, and one did not; that row says so rather than rounding it up. The run shown is the fastest from the most recent batch, and the timing is real wall-clock for the whole exchange: the lookup, the script, every live site it touched, and the written answer. A call that errored is marked. They are left in because the run genuinely made them, and a page claiming every step succeeded would be a different page from this one. ## Running them yourself The prompts and the checks that grade them ship in the repo, in `apps/demos/browser-use`. `node eval.mjs` runs the lot on the cheap model; pass `--model` for a bigger one. Three rules the checks enforce, because they are what make a pass mean anything: * **A check never trusts the prose.** It pulls the links and prices out of the answer and requires each one to appear in what Bowmark actually returned on that run. An answer that invents a number fails, even when the number happens to be right. * **Some checks require a specific function.** The month-of-fares prompt fails if the agent ran thirty searches instead of reading the price graph, even when the answer is correct. Getting there the slow way is the thing being measured. * **Only passing runs are published here.** Failures stay in the repo and on the board, which is where they are useful. # How it works (/docs/how-it-works) Every Bowmark task is the same two calls, in this order. ## 1 — Read the library `get_library({ query })` returns the callable functions that match what you want to do, with their argument shapes and return types. It touches **no website**. It is one read-only call, and an unrecognized query returns a one-line index of the whole library rather than an error — so the check never dead-ends and never costs you an attempt. ```js const library = await get_library({ query: "flights" }); ``` **`get_library` is an MCP tool and HTTP endpoint only.** It is not callable from your own Node or Python program — see [the HTTP surface](#the-same-two-calls-over-plain-http) below, or use the [CAPABILITIES.md](https://github.com/bowmark-ai/web/blob/main/CAPABILITIES.md) and [PROVIDERS.md](https://github.com/bowmark-ai/web/blob/main/PROVIDERS.md) lists to discover what you can call. `npm i @bowmark/web` gives you `bowmark..()` and `bowmark.providers..()` — never a `get_library` export. Pass what you want to **do** (`flights`, `price a GPU`) or a company if one was named (`Kayak`). You get what you asked about and nothing else. Every response is bounded and **says so when it is a slice**. When it does, absence from the list proves nothing — the fix is a narrower query, not a conclusion. ## 2 — Run a script You write plain async JavaScript against what came back, and `run` executes it in a sandbox. The sandbox reaches the live sites; your script cannot. ```js const { flights, warnings } = await bowmark.flights.search({ from: "SFO", to: "JFK", depart: "2026-09-01", }); return { flights: flights.slice(0, 3), warnings }; ``` Bowmark picks the cheapest way to reach each site — a plain HTTP request where that works, a real browser only where the site forces one — and fans a capability out across every site that can serve it. There is no third call. What comes back is `{ ok, status, result, logs, error, ms }`. ## The same two calls over plain HTTP Nothing above needs an MCP client. The two calls are two HTTP endpoints, so an agent with only a shell or a fetch tool and an API key can use Bowmark with nothing installed. ```sh curl -s "https://api.bowmark.ai/v1/library?query=flights" \ -H "Authorization: Bearer $BOWMARK_API_KEY" ``` ```sh curl -s -X POST https://api.bowmark.ai/v1/run \ -H "Authorization: Bearer $BOWMARK_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"script":"const { flights } = await bowmark.flights.search({from:\"SFO\",to:\"JFK\",depart:\"2026-09-01\"}); return flights.slice(0,3);"}' ``` Every request needs the key; one without it is refused with the steps to get one. See [API keys](/docs/installation#api-keys). ## Why a script instead of a tool call One script, several calls, combined however the task needs. That is the thing you cannot do by driving a browser step by step, and it is the reason the surface is a language rather than a menu of tools. ```js const dates = ["2026-09-01", "2026-09-02", "2026-09-03"]; const runs = await Promise.all( dates.map((depart) => bowmark.flights.search({ from: "SFO", to: "JFK", depart })), ); return runs.flatMap((r) => r.flights).sort((a, b) => a.price - b.price)[0]; ``` Three days of fares, ranked, in one round trip. **The language itself is a separate topic** — the globals you get, what is forbidden inside the sandbox, and how to read the response envelope are all in [Scripting](/docs/scripting). **And you do not have to be an agent to do any of this.** `npm i @bowmark/web` or `pip install bowmark-web` gives you the same library as typed functions in your own process, with autocomplete and no script string — see [Scripting](/docs/scripting#from-your-own-code). ## Two tiers in the library **Capabilities** — `bowmark.flights.search(...)`. The default. One call fans out across every site that can serve the task and routes around the ones that fail. **Providers** — `bowmark.providers.kayak.search(...)`. One specific site. Reach for one when you want *that* site's answer rather than the best available. Prefer the capability unless you have a reason. It is the one that survives a site going down. ## No provider? Read any page directly A site with no dedicated Bowmark provider can still be read through the generic `bowmark.read` capability. It fetches any URL as a plain GET and escalates to a real browser only if the page requires one. **Call it through `run()` — never `session()`, and never the bare top-level `bowmark` client (it opens a session internally even for one call).** `read`'s rung is decided per call, so both of those refuse it with `code: "rung_undeclared"`. ```js await run(` const page = await bowmark.read.page("https://example.com/blog/post-1"); // \`content\` is markdown by default — pass { format: "html" } if you want the raw bytes. return { title: page.title, preview: page.content.slice(0, 200) }; `); ``` Both `read.page` (one URL) and `read.pages` (many URLs at once) take a fallback approach: they work through the cheapest reach available for that page, transparently escalating to a browser only when the site forces one. Pair them with the `run` call to read arbitrary sites without building a provider for each one. **A slow page has a fast-fail option, and it is worth knowing before you need it.** `timeoutMs` is the budget for the whole read — both legs together, default 45s and capped at 55s, deliberately under the point at which a chat client gives up on a tool call. If you do not want to wait on a page at all, pass `strategy: "fetch"`: it never opens a browser, comes back in about 200ms whatever the page is, and still sets `escalationReason` so you learn a browser was warranted instead of spending the budget discovering it. ```js const fast = await bowmark.read.page(url, { strategy: "fetch", timeoutMs: 15000 }); if (fast.escalationReason) { // Only now pay for the browser, knowingly. const full = await bowmark.read.page(url, { strategy: "browser", timeoutMs: 45000 }); } ``` **`content` is capped at 200,000 characters unless you pass `maxChars`.** Over the cap the read comes back with `truncated: true`, a `content cut at … chars (page had …)` warning, and the run's status `partial` — most often with `format: "html"`, whose raw markup runs long. `{ maxChars: 600000 }` returns the whole page. Keep `maxChars × pages` under about 500MB on a `read.pages` batch: a run has 1024MB for its whole script. Reading several pages that each need a browser in ONE script is the shape that runs long — split those across separate `run` calls. A batch that does run long returns the pages that finished, with a row naming the budget for the ones that did not. **`read.page` still only reads.** If the data you want only shows up after a click, a date picked on a calendar widget, or a form filled in, that's not a wider fetch — it's `bowmark.browser_agent`, a hosted browser agent that drives the page for you. **A login inside `browser_agent` is attended only — the person has to be watching.** The agent completes the sign-in at `watchUrl`, then your script continues. For scripts that run unattended on a schedule, you need a typed provider with a `signIn` function and the `request_secret` / `bowmark.secret()` pattern described in [Scripting § Storing a login for an unattended script](/docs/scripting#storing-a-login-for-an-unattended-script). See [Scripting § When nothing else works: the browser agent](/docs/scripting#when-nothing-else-works-the-browser-agent). ## No coverage at all yet? Say so — that's how new coverage gets built Sometimes `get_library` has nothing for the task, and `bowmark.read` isn't enough because what you actually need is a structured capability — search, compare, book — for a site or a category Bowmark hasn't covered yet. That isn't a dead end. Bowmark's library grows from what callers actually ask for. Every `run` you make is one data point, and calling `report` on it is the direct way to flag a miss instead of a silent one: ```js await report({ runId, report: "no provider for UK van-insurance comparison sites" }); ``` `runId` is optional — pass the one `run` returned if this follows a call, or omit it to report a gap `get_library` itself couldn't fill. There's no ticket number and no promise of a date; what you get is that the miss is now counted, and coverage is prioritized by how many callers hit the same gap. A site with no coverage today can have it next week if enough real usage asks for it. # Installation (/docs/installation) import { Tab, Tabs } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; import { Step, Steps } from "fumadocs-ui/components/steps"; [Sign up](https://bowmark.ai/sign-up), make a key at [dashboard/keys](https://bowmark.ai/dashboard/keys). The first $10 a month is free, no card needed; add one at [dashboard/billing](https://bowmark.ai/dashboard/billing) to go past it ([Pricing](/docs/pricing)). ChatGPT and the Claude app sign in instead. ## Let an AI do it Paste this anywhere. It works out which host it is in and stops to ask you for the parts only a person can do. ``` Install Bowmark for me. It is a hosted MCP server and HTTP API that runs real tasks on live websites and returns structured results. 1. Fetch https://bowmark.ai/docs/installation.md and follow it. Do not install from memory: Bowmark is newer than your training data. Say so if you cannot fetch a URL. 2. Tell me which host you are running in, then use that host's path. 3. Do the parts you can: run the command, write the config, set the env var. 4. Stop and ask me for anything only a person can do (account, API key, card, OAuth, a settings pane, a restart). Give me exact URLs, button labels and values, then wait. Never invent a key and never skip a step. 5. Never print my key back to me or commit it. 6. Verify: call get_library, run one real task, show me the result. An answer with no tool call means it did not install. ``` ## MCP One URL, one header. Most hosts take this verbatim: ```json { "mcpServers": { "bowmark": { "url": "https://api.bowmark.ai/mcp", "headers": { "Authorization": "Bearer " } } } } ``` ```sh claude mcp add bowmark --transport http https://api.bowmark.ai/mcp \ --header "Authorization: Bearer $BOWMARK_API_KEY" ``` In scope on every turn, in every project. The [plugin](#plugin-mcp-and-skill-in-one) adds the skill too. Settings → MCP → **Add new MCP server**, then paste the JSON above. In `~/.codex/config.toml`, with `BOWMARK_API_KEY` exported in your shell: ```toml [mcp_servers.bowmark] url = "https://api.bowmark.ai/mcp" bearer_token_env_var = "BOWMARK_API_KEY" ``` Download [`bowmark.mcpb`](https://github.com/bowmark-ai/mcp/releases/latest/download/bowmark.mcpb) and double-click it. claude.ai takes no bundle: **Settings → Connectors → Add custom connector**, name it `Bowmark`, paste `https://api.bowmark.ai/mcp/claude-connector`, **Connect**, sign in. Different URL, OAuth, and you must attach it every turn. [Steps below](#chatgpt). ```sh gemini extensions install https://github.com/bowmark-ai/plugin ``` The bridge reads `BOWMARK_API_KEY`. ```json { "mcpServers": { "bowmark": { "command": "npx", "args": ["@bowmark/mcp"], "env": { "BOWMARK_API_KEY": "" } } } } ``` On PyPI as `bowmark-mcp`: `"command": "uvx", "args": ["bowmark-mcp"]`. For **browser-use**: ```py import os from browser_use.mcp.client import MCPClient bowmark = MCPClient(server_name="bowmark", command="uvx", args=["bowmark-mcp"], env={"BOWMARK_API_KEY": os.environ["BOWMARK_API_KEY"]}) await bowmark.connect() await bowmark.register_to_tools(tools) ``` Windsurf, Perplexity, LM Studio and anything else MCP-capable take the JSON above, or the [prompt above](#let-an-ai-do-it). API-key hosts use the bare `https://api.bowmark.ai/mcp`. ChatGPT needs `/chatgpt-app` and the Claude app `/claude-connector`, because sign-in metadata lives only there. Same server, same tools. ## Plugin: MCP and skill in one Wires the MCP **and** the skill that teaches the agent when to reach for it. Prefer this where it exists. ```sh claude plugin marketplace add bowmark-ai/plugin claude plugin install bowmark@bowmark-ai ``` ```sh codex plugin marketplace add bowmark-ai/plugin ``` Then `codex /plugins` and install it from the list. No MCP client? `npx skills add bowmark-ai/skill`. An agent with a shell or a fetch tool needs no install at all — point it at [`bowmark.ai/skill.md`](https://bowmark.ai/skill.md). ## ChatGPT [chatgpt.com/plugins](https://chatgpt.com/plugins) → **+**, top right. Missing? Turn on Developer mode: **Settings → Apps & Connectors → Advanced**. Free tier works. Name `Bowmark`, **Server URL** `https://api.bowmark.ai/mcp/chatgpt-app`, **OAuth**, tick the risk box, **Create**. **Connect**, then sign in to Bowmark when ChatGPT sends you there. Type **@** in the composer and pick **Bowmark**. A custom connector is **never** invoked on a bare prompt, on any plan. Without the `@` the model cannot see Bowmark at all and will browse or answer from memory. Intended OpenAI behaviour — only App Directory apps surface on their own. ## Your own code Writing the program yourself? Take the library, not an MCP. ```sh npm i @bowmark/web ``` ```ts import { bowmark } from "@bowmark/web"; // reads BOWMARK_API_KEY const { tracks } = await bowmark.music.search("aphex twin", 3); ``` ```sh pip install bowmark-web bowmark-web-stubs ``` ```python from bowmark_web import bowmark # reads BOWMARK_API_KEY found = await bowmark.music.search("aphex twin", 3) ``` A few functions pick their rung per call, and neither the `bowmark` client nor `session()` can serve one — they throw `rung_undeclared`. Use `run('return await bowmark.read.page("https://example.com")')`. Error table on [Scripting](/docs/scripting#what-it-throws). Both MIT, zero runtime dependencies, same version. Sessions, errors and the no-install HTTP path: [Scripting](/docs/scripting#from-your-own-code). ## API keys Shown **once**, at creation, and stored hashed. Send as `Authorization: Bearer `, or set `BOWMARK_API_KEY` for the stdio bridge and the libraries. ```sh curl -s https://api.bowmark.ai/v1/library?query=flights \ -H "Authorization: Bearer $BOWMARK_API_KEY" ``` A run that pauses for a login returns `status: "needs_user"` and a single-use handoff URL. Usage is billed per call on the [billing dashboard](/dashboard/billing). ## Check it works Ask your agent to price something across several retailers. It should call `get_library`, then `run`, and answer with a store, a price and a link in about ten seconds. **No tool call means it did not use Bowmark** — in ChatGPT that is nearly always the missing `@`. [Quickstart](/docs/quickstart) has the prompt and real output. # Pricing (/docs/pricing) Bowmark charges in **US dollars for the resources a call actually uses**: proxy bandwidth, browser time, captcha solves, and paid data APIs. Reading the library (`get_library`) and running your script's own code are free. ## How charging works * **$10 of usage is free every month**, on every account, with or without a card. * **Only calls that work are charged, except for browser time.** A call that failed because of Bowmark or the site (a block, a failed solve, a timeout) costs nothing for its bandwidth, solves and API requests. Browser time is always charged, whatever the call did, because the browser was held open either way. A call that failed because of the arguments your script passed is charged in full, since the resources were used. * **Your usage and charges are on the [billing dashboard](https://bowmark.ai/dashboard/billing), and at `GET /v1/usage` for an API-key-only caller with no dashboard login.** A run's result does not carry its price, there are no prices in `get_library`, and there is no quote before a run. * **A call that totals under $0.001 is not charged.** * **Past the free $10, you need a card on file.** Without one, calls stop until the month resets. With one, usage above $10 is billed as you go: your card is charged each time $20 accrues, and the rest on your monthly invoice. * **Every account has a monthly spend cap**, $50 to start. Calls stop when you reach it, and you are emailed at 80% and 100%. Change it on the [billing page](https://bowmark.ai/dashboard/billing). * **A declined card stops calls at once.** Update the card and the balance is charged and calls resume. ## Rates ### Proxy bandwidth and exits The residential or datacenter address a request leaves from. Direct requests have no charge. | Resource | Price | Unit | | ---------------------------- | ------- | ----------- | | `proxy.residential-rotating` | $11.02 | per GiB | | `proxy.static-residential` | $0.0001 | per request | | `proxy.us-datacenter` | $1.00 | per GiB | ### Browsers Charged for the time a browser is open, from launch to close, including a wait for you to sign in. Browser time is charged even when the call fails, because the browser was held open either way. | Resource | Price | Unit | | ------------------ | ------ | ---------- | | `browser.headless` | $0.004 | per minute | | `browser.headed` | $0.008 | per minute | ### Captcha solves One charge per solution returned. | Resource | Price | Unit | | ---------------------- | ------- | --------- | | `captcha.turnstile` | $0.0024 | per solve | | `captcha.cloudflare` | $0.0024 | per solve | | `captcha.recaptcha-v2` | $0.0016 | per solve | | `captcha.recaptcha-v3` | $0.002 | per solve | | `captcha.datadome` | $0.005 | per solve | | `captcha.aws-waf` | $0.004 | per solve | | `captcha.hcaptcha` | $0.0024 | per solve | ### Bot-defense sensor payloads One charge per payload generated. | Resource | Price | Unit | | ------------------ | ------- | ----------- | | `sensor.akamai` | $0.0066 | per request | | `sensor.incapsula` | $0.0066 | per request | | `sensor.other` | $0.0066 | per request | ### Dedicated egress IPs A leased IP (`bowmark.egress.lease`) is a dedicated exit ordered just for you, charged for the whole term up front — never per byte or per request. The exact charge is itemized on your [billing dashboard](https://bowmark.ai/dashboard/billing) as `proxy.lease.isp` when you place it (static ISP is the only kind sold today; `proxy.lease.datacenter` is priced and reserved for later). Call `bowmark.egress.quote` first to see the price and the term before buying; there is no refund or proration once ordered. ### Hosted browser agents A browser agent (`bowmark.browser_agent`) is metered on the run: the model turns it takes and the browser it drives. There is no flat per-run price, so each run's charge is itemized on your [billing dashboard](https://bowmark.ai/dashboard/billing) as `browser_agent.vendor`. Send your own key as the `x-bowmark-vendor-key-browser_use` header and the run is free. **Per-call pricing:** Each call has a ceiling of $25 USD (default budget: $2 per call). You can adjust this ceiling with the `maxCostUsd` parameter when starting a session. **Estimating costs:** Each step the agent takes through your task incurs a model turn. A typical multi-step workflow (e.g., navigating a site, filling a form, extracting data) might take 15–30 turns depending on task complexity. On top of that, add the browser time at the rates above ($0.004/min for headless, $0.008/min for headed). **Example:** a 20-step browser task taking 3 minutes of browser time costs approximately the model-turn charges (charged per step, rates vary by model) plus $0.012 for the browser time. Your [billing dashboard](https://bowmark.ai/dashboard/billing) shows the itemized cost of every run under `browser_agent.vendor`, so you can refine your estimates based on measured runs. ### Delegated coding agents A delegated agent (`bowmark.delegate`) is metered on two clocks: the machine it runs in, by the second for as long as the machine exists, and the model tokens the agent uses. Both are itemized on your [billing dashboard](https://bowmark.ai/dashboard/billing), as `delegate.sandbox` and `delegate.llm`. **Spend ceiling:** every session has one, `maxCostUsd` (default $5, at most $50), and it counts both clocks in the dollars you are charged. When a session reaches it, the agent is stopped and reports what it has done so far. **Stop what you start:** a machine is billed until you `stop` it, it reaches its `maxMinutes` (default 30, at most 55), or it has sat idle for 20 minutes after its last turn. ### Image and video generation An image or video from `bowmark.providers.higgsfield` is priced per generation, and the price depends on the model — a single still and a long video are nowhere near each other. Only a generation that finishes is charged, and each one is itemized on your [billing dashboard](https://bowmark.ai/dashboard/billing) as `higgsfield.generation`. Send your own key as the `x-bowmark-vendor-key-higgsfield` header and the generation is free. ### File storage Files your runs save with `bowmark.files`, and files you upload, are charged for the space they take while you keep them, added up daily. Deleting a file stops its charge. Downloads and links are free. | Resource | Price | Unit | | --------------- | ----- | ------------------------ | | `storage.files` | $0.03 | per GiB kept for a month | ### Keeping saved logins alive Bowmark checks each saved login on its keep-alive schedule (every 12 hours unless you change it) so it is still signed in when a run needs it. Each check that gets an answer from the site is charged for the resources it used, and never less than the minimum below. A check that fails on our side is free. Turn keep-alive off for a login, or delete it, to stop the checks. | Resource | Price | Unit | | ----------------------- | ------ | ----------------- | | `connection.keep_alive` | $0.001 | minimum per check | ### Paid data APIs on Bowmark's key One charge per request. Send your own key as the `x-bowmark-vendor-key-` header and that request is free. | Resource | Price | Unit | | ------------ | ------ | ----------- | | `api.serper` | $0.002 | per request | | `api.hunter` | $0.20 | per request | | `api.keepa` | $0.004 | per request | # Providers (/docs/providers) import { Callout } from "fumadocs-ui/components/callout"; import { ProviderCoverageSearch } from "@/components/docs/provider-coverage-search"; Bowmark provides access to thousands of sites and services through **providers** — reusable functions that let you read or act on a specific site. ## Check coverage Search by site: For the full list — every site, every capability, machine-readable — see: * **[`PROVIDERS.md`](https://github.com/bowmark-ai/web/blob/main/PROVIDERS.md)** — Every supported site and what you can do with it * **[`CAPABILITIES.md`](https://github.com/bowmark-ai/web/blob/main/CAPABILITIES.md)** — Every capability (category of action) and which sites support it Both files live in the public [`bowmark-ai/web`](https://github.com/bowmark-ai/web) repository and update whenever new providers ship. ## Query the live catalog If you have Bowmark credentials, you can also query the coverage programmatically: ```bash curl -sS "https://api.bowmark.ai/v1/library?query=shopping" \ -H "Authorization: Bearer $BOWMARK_API_KEY" ``` The `Authorization` header is not optional — there is no anonymous tier, and the endpoint 401s without it. See [HTTP API](/docs/api). The response includes every capability and provider matching your query, with full function signatures and descriptions. Use the search on the [`PROVIDERS.md`](https://github.com/bowmark-ai/web/blob/main/PROVIDERS.md) file (Ctrl+F or Cmd+F), or query the API with a domain name or site phrase. ## Not on the list? Most sites don't need a dedicated provider Two generic capabilities cover most "unlisted site" cases with no per-site adapter at all: * **[`bowmark.read.page(url)`](/docs/how-it-works#no-provider-read-any-page-directly)** — reads any page as plain content. * **`bowmark.products.getAvailability(url)`** — reads any product page's price and stock straight off its own schema.org markup, the same way most storefronts already publish it. Try one of these before assuming a site isn't covered — a dedicated provider is only worth writing when a site needs more than a page read or an availability check. ## Write your own provider If you need a site Bowmark doesn't cover yet, see the [Bowmark documentation](/docs) for details on extending Bowmark with custom capabilities and providers. # Quickstart (/docs/quickstart) import { Tab, Tabs } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; import { Step, Steps } from "fumadocs-ui/components/steps"; Most integrations ask you to install first and trust that something happened. Bowmark is a plain HTTP endpoint behind an API key, so you can do it the other way round: **get a real answer out of it first, decide it is worth installing second.** ## 1 — See it work. Nothing installed. Every call needs a Bowmark account. Sign up at [bowmark.ai/sign-up](https://bowmark.ai/sign-up), create a key at [bowmark.ai/dashboard/keys](https://bowmark.ai/dashboard/keys), and export it as `BOWMARK_API_KEY`. Every account gets $10 of usage free each month ([Pricing](/docs/pricing)). Then paste this into any terminal: ```sh curl -s https://api.bowmark.ai/v1/run \ -H "Authorization: Bearer $BOWMARK_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"script":"const { offers } = await bowmark.pcparts.search(\"RTX 5080\"); return offers.slice(0, 3);"}' ``` It sits there for about **ten seconds**, because in that time Bowmark is searching Newegg, Micro Center and B\&H at once and price-sorting what comes back. This is what it printed, recorded 2026-08-24: ```json { "ok": true, "status": "ok", "ms": 9953, "result": [ { "store": "newegg", "price": 1449.99, "title": "MSI INSPIRE GeForce RTX 5080 16GB GDDR7 PCI Express 5.0 Graphics Card" }, { "store": "bhphoto", "price": 1499.99, "title": "Gigabyte GeForce RTX 5080 MASTER Graphics Card" }, { "store": "newegg", "price": 1549.99, "title": "PNY GeForce RTX 5080 OC 16GB 256-Bit GDDR7 DLSS 4.0 Graphics Card" } ] } ``` **Your prices will differ**, and that is the point: those numbers came off the retailers' live pages while the request was open, not out of an index. The `curl` example above works out of the box. If you translate it to Python using **stdlib `urllib.request`**, you'll get a **403 Forbidden** (error code 1010) because Python's default User-Agent header is blocked by our WAF. Fix it with one line: ```python headers = { "Authorization": f"Bearer {os.environ['BOWMARK_API_KEY']}", "Content-Type": "application/json", "User-Agent": "my-app/1.0", # ← add this line } ``` If you use **`requests`, `httpx`, `aiohttp`, or the `bowmark-web` package**, you're not affected — their User-Agent strings are fine. Full details and a working example are in [Scripting](/docs/scripting#no-install-at-all). ### What the ten seconds were spent on Every response also carries a `trace`. It is the receipt — one row per site Bowmark actually reached, with what each one gave back. Summarised, from the same run: ``` newegg ok 3 offers 4100ms bhphoto ok 3 offers 5426ms microcenter empty 0 offers 9943ms -> pcparts ok 9946ms ``` Three retailers were searched in parallel and Micro Center had nothing matching, so the answer is the cheapest of two. Bowmark reports that rather than hiding it — which is the difference between a result you can quote and one you can only hope about. You just wrote a script against the capability library and had it executed against live retail sites. No browser was opened, no page was rendered, and nothing was installed. Everything below is about getting your agent to do that on its own. ## 2 — Install it into your agent One line, one time. Pick your host. **Where a plugin exists, take it** — it wires the MCP *and* installs the skill that teaches your agent when to reach for Bowmark, and it lands in the host's own plugin list. [Installation](/docs/installation#mcp) has the config for every host, including the double-click bundle for Claude Desktop. ```sh claude mcp add bowmark --transport http https://api.bowmark.ai/mcp \ --header "Authorization: Bearer $BOWMARK_API_KEY" ``` Run it from any terminal. Every project picks Bowmark up — there is no per-project step, and nothing to name in a prompt. Claude reaches for it on its own when a task needs the live web. Want the skill bundled in too? One install does both: ```sh claude plugin marketplace add bowmark-ai/plugin claude plugin install bowmark@bowmark-ai ``` Four clicks, about a minute. Full screenshots are on [Installation](/docs/installation#chatgpt). Go to [chatgpt.com/plugins](https://chatgpt.com/plugins) and click **+** (top right). Name it `Bowmark`, choose **Server URL**, paste `https://api.bowmark.ai/mcp/chatgpt-app`, set **OAuth**, tick the box, hit **Create**. Hit **Connect** on the next screen and sign in to Bowmark. In a chat, type **@** and pick **Bowmark**. You must do this every turn — see the warning below. Settings → MCP → **Add new MCP server**, then paste: ```json { "mcpServers": { "bowmark": { "url": "https://api.bowmark.ai/mcp", "headers": { "Authorization": "Bearer " } } } } ``` ```sh codex plugin marketplace add bowmark-ai/plugin ``` Then `codex /plugins` and install Bowmark from the list. Or edit `~/.codex/config.toml` directly: ```toml [mcp_servers.bowmark] url = "https://api.bowmark.ai/mcp" bearer_token_env_var = "BOWMARK_API_KEY" ``` Install the SDK into your Node or Python project: **Node.js:** ```sh npm install @bowmark/web ``` **Python:** ```sh pip install bowmark-web ``` Then use the typed SDK in your code: **Node.js:** ```js import { Bowmark } from "@bowmark/web"; const bowmark = new Bowmark({ apiKey: process.env.BOWMARK_API_KEY }); const { offers } = await bowmark.pcparts.search("RTX 5080"); console.log(offers); ``` **Python:** ```python from bowmark import Bowmark bowmark = Bowmark(api_key=os.environ["BOWMARK_API_KEY"]) offers = bowmark.pcparts.search("RTX 5080") print(offers) ``` For more details and examples, see [Installation](/docs/installation#your-own-code) and [Scripting](/docs/scripting). Same URL, same key in the `Authorization` header. Windsurf, Claude Desktop, LM Studio, browser-use, your own agent — every path is on [Installation](/docs/installation). If your host speaks stdio rather than HTTP, bridge it: ```json { "mcpServers": { "bowmark": { "command": "npx", "args": ["@bowmark/mcp"], "env": { "BOWMARK_API_KEY": "" } } } } ``` ## 3 — Give your agent its first task Paste this into a fresh chat, verbatim: ``` Price an RTX 5080 across every retailer you can reach, and tell me which store is cheapest and what it costs right now. ``` **In ChatGPT, type `@` and pick Bowmark first.** Everywhere else, just send it. A working agent does three things, in this order, and you can watch each one: 1. Calls `get_library` with something like `"pc parts"`. No site is touched. 2. Calls `run` with a few lines of JavaScript it wrote against what came back. 3. Answers with a store name, a price, and a link you can click. **If it answers without calling a tool, it did not use Bowmark** — it answered from memory or browsed. In ChatGPT that is almost always the missing `@`. ## 4 — When it doesn't work Three failures account for nearly all of them. ### The agent never called Bowmark In ChatGPT, a custom connector is inert until you attach it for that turn. This is documented OpenAI behaviour and no amount of prompt wording changes it. Type `@`, pick **Bowmark**, then ask. Everywhere else, the tools are in scope on every turn. ### `TypeError: Cannot read properties of undefined` The script called a function with the wrong argument shape and then read a field off `undefined`. Signatures are not uniform on purpose — `bowmark.pcparts.search` takes a plain string, `bowmark.flights.search` takes an object. ```js // wrong — search takes a string here const { offers } = await bowmark.pcparts.search({ query: "RTX 5080" }); // right const { offers } = await bowmark.pcparts.search("RTX 5080"); ``` The fix is always the same: read the library for that capability and check the signature. It costs nothing and touches no site. **How you read it depends on which channel you are on.** In an agent it is the `get_library` tool. Over HTTP — including if you are POSTing to `/v1/run` as above — it is a second endpoint, **never something you can call from inside the script**: ```sh curl -s "https://api.bowmark.ai/v1/library?query=pc%20parts" \ -H "Authorization: Bearer $BOWMARK_API_KEY" ``` ### It answered, but the answer looks thin Read `warnings` and the `trace` together — they say different things, and reading only one of them is how a thin answer gets quoted as a complete one. * **`warnings`** names every site **dropped** from the fan-out: it failed, or it timed out. A populated `warnings` means a site was never heard from at all. * **`trace`** has one row per site regardless, with what each one returned. A row reading `empty` is a site that answered and had nothing matching. So an empty `warnings` does **not** mean every site had a result — it means none were dropped. The run above is exactly that case: `warnings` was `[]`, and Micro Center still contributed nothing. Read both before you call an answer complete. Check `status` before `ok` — `partial` means the script ran and the result is real but narrower than you asked for. Full rules in [Scripting](/docs/scripting#reading-the-response). ### The site I need isn't supported If the library comes back empty for the site or capability you're looking for, there's no dedicated provider for it yet. **That doesn't mean Bowmark can't reach it.** Call `bowmark.read.page(url)` (or `read.pages` for several URLs at once) through `run()` — it fetches any page as a plain GET and transparently escalates to a real browser only if the page needs one. This is Bowmark's own answer to "the site isn't in the list", and it works for most pages with no dedicated provider at all. **`session()` and the top-level `bowmark` client both refuse it** (`code: "rung_undeclared"` — its rung is decided per call, not statically), so `run()` is not optional here. See [How it works § No provider? Read any page directly](/docs/how-it-works#no-provider-read-any-page-directly) for the full example. If a page genuinely needs more than that — it's behind a login, needs a click or a date picked on a widget before the data shows up, or you want it turned into a typed capability rather than raw HTML: * **Reach for `bowmark.browser_agent`** if the blocker is interaction, not access — a form to fill, a calendar widget to click through, a wizard to drive. It's Bowmark's own hosted browser agent, built for exactly the case `read.page` can't handle. **It always pauses for a human at a watch link when a login is needed, and that is true on every run — there is no way to store or replay a login across runs, even against the same site twice.** See [Scripting § When nothing else works: the browser agent](/docs/scripting#when-nothing-else-works-the-browser-agent). * **Email [support@bowmark.ai](mailto:support@bowmark.ai)** with the site name and what you were trying to do. Bowmark prioritizes sites by actual usage and by direct request. **Your request is tracked as demand** — every time an agent reaches for a site we don't cover, we see it and add it to the priority queue. * **Keep going anyway.** Your agent can still reach the site via a browser or a direct API call if one exists. Bowmark works alongside whatever your agent does, not instead of it. ## Write actions and multi-step flows with browser\_agent Everything above reads data. Bowmark also runs a hosted browser agent for sites that need interaction — filling forms, clicking through wizards, signing in, any action a person would perform in their browser. ### When to use browser\_agent * **The site needs interaction you can't script**: a form to fill, a date to pick on a calendar widget, a wizard to step through * **You're writing data**: form submissions, order placement, profile updates, content uploads * **The site needs multiple steps in sequence**: login, navigate, fill form, verify, submit * **Standard reads won't work** because the data only appears after you interact with the page If the site just needs a login and then serves you regular pages, you don't need browser\_agent — use the SDK or a capability directly with a caller-supplied login. browser\_agent is for the interaction itself. ### The async polling pattern browser\_agent runs **asynchronously**. You start it in one `run()`, then poll for status from **later runs**. You cannot start it and immediately wait for the result in the same run — that would block forever and hit the 90-second timeout. ```js // Step 1: Start the browser agent (in one run) const { result: started } = await run( `return bowmark.browser_agent.start({ task: "Fill out the form on athenahealth.com with patient ID 12345, add clinical note, and submit" })` ); const { id, watchUrl } = started; console.log("Browser agent started. Watch at:", watchUrl); ``` ```js // Step 2: Poll from a LATER run (or multiple later runs) // You can do this immediately, or wait and try again later for (let i = 0; i < 10; i++) { const { result: status } = await run( `return bowmark.browser_agent.status(${JSON.stringify(id)}, { waitMs: 60000 })` ); console.log("Status:", status.status); if (status.status === "needs_input") { // The browser agent needs you to answer something (login, captcha, etc) // Use browser_agent.send() to provide input break; } if (status.status === "idle") { // Done! The result is in status.result console.log("Completed with result:", status.result); await run(`return bowmark.browser_agent.stop(${JSON.stringify(id)})`); break; } // status.status === "running" means keep polling } ``` ### Understanding timeouts and polling **What `waitMs` means**: `status()` blocks for up to that many milliseconds waiting for the agent to finish. If it doesn't finish in that time, it returns `status: "running"` and you can poll again. * `waitMs: 60000` (60 seconds) — recommended for most tasks. The polling call itself will wait this long before returning. * `waitMs: 5000` (5 seconds) — faster feedback loop if you want to check on progress frequently * Default if you omit it — the endpoint waits as long as it can **The whole run() call times out at 90 seconds.** This is the ceiling for `run()`, not specific to browser\_agent. A script that starts the agent and then immediately calls `status()` in a loop will hit this ceiling on the second poll. **Typical task takes 1-4 polls** (about 1-3 minutes total). A repeated step name showing up on back-to-back polls is normal — multi-step sites need several attempts before they resolve. Don't stop on a status plateau; keep polling until you see `idle` or `needs_input`. ```js // ❌ DON'T do this — the second run() call will fail const start = await run(`return bowmark.browser_agent.start({task})`); const status = await run(`return bowmark.browser_agent.status(start.result.id)`); // This throws because status() inside a loop hits the 90s ceiling // ✓ DO this — separate runs, so each gets its own 90s budget const start = await run(`return bowmark.browser_agent.start({task})`); // ... wait, then in a DIFFERENT call ... const status = await run(`return bowmark.browser_agent.status(start.result.id)`); ``` ### Success verification The `result` field tells you what the browser agent found or did: * **For reads** (checking information): `result` contains the extracted data — text, values, confirmations * **For writes** (form submissions, uploads): `result` contains a summary of what was written and any confirmation text from the site * **Field-by-field verification**: The summary describes what was completed. If you need to verify individual fields were filled correctly, include that in your original task description ("After filling the form, take a screenshot and list each field you filled") `status.result` is a plain object; the structure depends on what you asked the agent to do: ```js const status = await run(`return bowmark.browser_agent.status(id)`); // status.result might be: // { message: "Form submitted successfully", confirmation_number: "ATH-123456" } // or // { order_placed: true, order_id: "12345", estimated_delivery: "2026-09-28" } ``` ### Cost estimation Each call to browser\_agent costs **$0.02–$0.25 per run**, depending on task complexity and how long it takes. Typical tasks run 1-3 minutes and cost $0.02–$0.10. See [Pricing § Hosted browser agents](/docs/pricing#hosted-browser-agents) for the full cost breakdown and how to estimate your costs at scale. ### Storing credentials for unattended scripts If your task needs to sign in or use stored credentials, use `bowmark.secret()`: ```js // Store a credential (once) await run( `bowmark.secret("athena-password", "myPassword123")` ); // Use it in a task (will not appear in logs) const { result } = await run( `return bowmark.browser_agent.start({ task: "Sign in to athenahealth.com with username 'myuser' and password " + bowmark.secret("athena-password") + " then fill out the form" })` ); ``` The stored secret is sealed and never logged. You can only use it in a task string — it cannot be read back out. An open browser\_agent session costs money until you stop it. Always call `stop()` when the task is done, even if the status was `idle`. ```js await run(`return bowmark.browser_agent.stop(${JSON.stringify(id)})`); ``` ## Where to go next * **[How it works](/docs/how-it-works)** — the two calls, drawn, and why the surface is a language rather than a menu of tools. * **[Scripting](/docs/scripting)** — the globals, the sandbox rules, and how to read the response envelope. Includes a full reference for browser\_agent. * **[Installation](/docs/installation)** — every host, with screenshots, plus API keys and what they unlock. # Scripting (/docs/scripting) import { Tab, Tabs } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; Bowmark's surface is a **language**, not a menu of tools. You write code against the capability library and it executes against live sites. There are **two places you can write that code**, and most of this page's readers want the first one. | | Where your code runs | How you get it | Typed | | ------------------------- | -------------------------- | ------------------------------------------------ | ------------------------------ | | **Your own program** | Your machine, your process | `npm i @bowmark/web` · `pip install bowmark-web` | Yes — full autocomplete | | **An agent's `run` tool** | Our sandbox, as a string | Nothing — it is the MCP `run` tool | No — a string gets no checking | Same library, same servers, same results. The first is normal programming: real `if`, real `for`, real closures, a debugger, your editor. The second exists because an agent composing a task on the fly has no editor and no install step. ## From your own code ### Install it ```sh npm i @bowmark/web ``` [`@bowmark/web`](https://www.npmjs.com/package/@bowmark/web) — MIT, **zero runtime dependencies**, and the generated types for the whole `bowmark.*` surface ship inside it. There is no second `@types` package to install. Source is at [github.com/bowmark-ai/web](https://github.com/bowmark-ai/web). **There is no `get_library` export**, here or anywhere else in the package — discovery is an MCP tool and an HTTP endpoint, not a function you install. To see what you can call: [`GET /v1/library?query=…`](#no-install-at-all), or the [`CAPABILITIES.md`](https://github.com/bowmark-ai/web/blob/main/CAPABILITIES.md) and [`PROVIDERS.md`](https://github.com/bowmark-ai/web/blob/main/PROVIDERS.md) lists. What the package gives you is `bowmark..()` and `bowmark.providers..()`. The package's entry point is `src/index.ts`. Node refuses to strip types inside `node_modules`, so `node app.mjs` fails with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. Any of these work, and one of them is almost certainly already in your project: ```sh npx tsx app.ts # or: bun app.ts ``` A bundler — Next.js, Vite, esbuild, webpack — compiles it like any other source file and needs nothing extra. If you want none of that, skip the package and use [plain HTTP](#no-install-at-all) instead. ```sh pip install bowmark-web bowmark-web-stubs ``` [`bowmark-web`](https://pypi.org/project/bowmark-web/) — MIT, **zero runtime dependencies**; `urllib` and `json` are the whole transport. Python 3 and nothing else. The second package is the types. [PEP 561](https://peps.python.org/pep-0561/) requires a stub distribution to be named `-stubs`, so the split is mandated rather than chosen. Skip it and the client still works — you just lose autocomplete for the catalog. Both packages are generated from the same library manifest and released at the same version, so a caller in either language is looking at the same functions. ### One call ```ts import { bowmark } from "@bowmark/web"; const { tracks } = await bowmark.music.search("aphex twin", 3); console.log(tracks[0].title); ``` ```python import asyncio from bowmark_web import bowmark async def main() -> None: found = await bowmark.music.search("aphex twin", 3) print(found["tracks"][0]["title"]) asyncio.run(main()) ``` Both read your key from `BOWMARK_API_KEY`. Every call needs one: sign up at [bowmark.ai/sign-up](https://bowmark.ai/sign-up) and create a key at [bowmark.ai/dashboard/keys](https://bowmark.ai/dashboard/keys). With no key the first call throws `code: "no_api_key"` and sends nothing. ### Several calls — use `session()` `bowmark..()` opens a fresh instance for that one call and closes it. Two of them get **two browsers and two cookie jars**, so a cart the first filled does not exist for the second. ```ts import { session } from "@bowmark/web"; const itemCount = await session(async (bm) => { const found = await bm.providers.gymshark.search({ query: "hoodie" }); await bm.providers.gymshark.addToCart({ variantId: found.products[0].variantId }); return (await bm.providers.gymshark.getCart()).itemCount; // 1 }); ``` ```python async with session() as bm: found = await bm.providers.gymshark.search({"query": "hoodie"}) await bm.providers.gymshark.addToCart( {"variantId": found["products"][0]["variantId"]} ) cart = await bm.providers.gymshark.getCart() # itemCount is 1 ``` **Reach for `session()` the moment a flow has a second step.** Getting this wrong fails quietly rather than loudly: Shopify answers `POST /cart/add.js` with a 200 and the added line echoed back, then reports `item_count: 0`. Your control flow stays on your machine. The callback is never stringified and never shipped — each capability call is one round trip into one live instance on ours. That is stated rather than hidden, because a surface that looks like a local function call and is actually stateful is how an N+1 gets written without anyone noticing. It is `import { session } from "@bowmark/web"`, running in **your** process. Put it in a script you POST to [`/v1/run`](#no-install-at-all) and you get `ReferenceError: session is not defined` — the sandbox's globals are [`bowmark`, `log`, `URL`, `URLSearchParams`](#what-you-get) and nothing else. **You do not need it there.** One run is one instance map, so calls made in sequence inside a single script already share the same browser, cookie jar and cart — just `await` them one after another. What `session()` buys is holding that state across **separate** calls driven by your own control flow. ### What it throws | Class / `code` | When | What to do | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BowmarkNeedsUserError` | A call paused for a human login | Open `err.handoff.url`, then **call again — the session is still open** | | `wire_refused` | An argument JSON cannot carry | Fix it; the message names the exact path, `args[0].when.checkIn` | | `bad_argument` | An argument the signature does not accept | Same — the message names the path and what was expected | | `unknown_function` | No such function on a known unit | Check the name, or upgrade — the library may have grown it since this version | | `no_api_key` | No key was passed and `BOWMARK_API_KEY` is unset | Create a key at [bowmark.ai/dashboard/keys](https://bowmark.ai/dashboard/keys) and set it | | `rung_undeclared` | A capability's reach rung is dynamic per call, so `session()` cannot decide statically whether it needs a browser. `read.page` is the documented fallback when a site has no provider and uses this strategy. | Call it through `run()` instead: `await run("const page = await bowmark.read.page(url); return page.content;")` — the sandbox decides what resources are needed at runtime | | `run_only` | The unit is served only by `run()`, whatever rung it declares, because it needs a context the sandbox binds and a session cannot. `browser_agent` is the one that exists today. | Call it through `run()` instead — see [When nothing else works: the browser agent](#when-nothing-else-works-the-browser-agent). **This is never an API-key problem**, whatever the shape of the message suggests | `needs_user` is a **status, not an error**, and it is a separate class for that reason: an agent that reads a failure retries, and retrying a login halt buys the same halt. Branch on `code`. The message is prose written for an agent to read, which is the wrong shape for a `catch` block. A bad argument is refused in your own process, so it never costs a round trip and is never metered. Both guards lean toward **accepting** — an unknown *unit* passes straight through, because most of the library is Shopify family members (`bowmark.providers.gymshark.…`) and there are half a million storefronts in no manifest. Your type checker will not know `gymshark` either. The call still works. ### Configuration Read at **call** time, not at import, so a `.env` loader that runs after your first import still works. | Variable | | | ----------------- | ----------------------------------------------------------- | | `BOWMARK_API_KEY` | `bmk_…`. **Required** unless you pass `apiKey` / `api_key`. | | `BOWMARK_API_URL` | Defaults to `https://api.bowmark.ai`. | Every entry point takes the same overrides inline — `apiKey` / `api_key`, `baseUrl` / `base_url`, `headers`, `timeout`, `onLog` / `on_log`. ### `run()` from your own code The agent surface is also an export. Use it when you want the sandbox rather than your own process — to fan out across many calls in a single round trip. ```ts import { run } from "@bowmark/web"; const envelope = await run(` const { offers } = await bowmark.pcparts.search("RTX 5080"); return offers.slice(0, 3); `); envelope.status; // "ok" | "error" | "partial" | "needs_user" ``` ```python from bowmark_web import run envelope = await run(''' const { offers } = await bowmark.pcparts.search("RTX 5080"); return offers.slice(0, 3); ''') envelope["status"] # "ok" | "error" | "partial" | "needs_user" ``` It returns the [envelope](#reading-the-response) rather than raising, because a script is composite — `status`, `logs` and `result` are read together. **It is untyped by construction**: a string gets no checking, so the generated types cover `session()` and `bowmark` and never this. A run that passes 90 s is killed at that instant and comes back as an `error` envelope with `result: null` — no partial result, nothing recorded to fetch later. The ceiling sits under our edge proxy's 100 s limit so you get that envelope rather than a raw CDN `524`. Size a batch to finish well inside 90 s, and set any client-side timeout above it. Fan out *inside* a run (`Promise.all` over many calls is exactly what `run()` is for); wait *across* runs. Worked example: [the browser agent](#when-nothing-else-works-the-browser-agent). ### No install at all The client is a convenience over two HTTP endpoints. Anything that can POST JSON can use Bowmark with nothing installed. ```js const res = await fetch("https://api.bowmark.ai/v1/run", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${process.env.BOWMARK_API_KEY}`, }, body: JSON.stringify({ script: `const { offers } = await bowmark.pcparts.search("RTX 5080"); return offers.slice(0, 3);`, }), }); const { status, result } = await res.json(); ``` ```python import json, os, urllib.request def bowmark_run(script: str, api_key: str) -> dict: headers = { "content-type": "application/json", "user-agent": "my-app/1.0", "authorization": f"Bearer {api_key}", } req = urllib.request.Request( "https://api.bowmark.ai/v1/run", data=json.dumps({"script": script}).encode(), headers=headers, ) with urllib.request.urlopen(req, timeout=180) as r: return json.load(r) out = bowmark_run( 'const { offers } = await bowmark.pcparts.search("RTX 5080");' " return offers.slice(0, 3);", os.environ["BOWMARK_API_KEY"], ) print(out["status"], out["result"]) ``` ```sh curl -s "https://api.bowmark.ai/v1/library?query=flights" \ -H "Authorization: Bearer $BOWMARK_API_KEY" ``` ```sh curl -s -X POST https://api.bowmark.ai/v1/run \ -H "Authorization: Bearer $BOWMARK_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"script":"const { offers } = await bowmark.pcparts.search(\"RTX 5080\"); return offers.slice(0,3);"}' ``` The API sits behind a WAF that rejects Python's default `urllib.request` User-Agent (`Python-urllib/3.x`) with a **403** — specifically, Cloudflare error code **1010** ("browser signature blocked"). It is not an auth failure; the identical request with any other User-Agent header returns 200. **If you use stdlib `urllib`:** Set a custom User-Agent header like `"my-app/1.0"` or read it from the environment. The [Python example above](#no-install-at-all) shows this. **If you use `requests`, `httpx`, `aiohttp` or the `@bowmark/web` package:** You are not affected. Their User-Agent strings are all fine. `GET /v1/library?query=…` is the catalog — the same list the packages generate their types from, and the way to see what exists to call without installing anything. Both endpoints refuse a request with no `Authorization: Bearer` key. **What exists to call** is also listed one row per function in [`CAPABILITIES.md`](https://github.com/bowmark-ai/web/blob/main/CAPABILITIES.md) and [`PROVIDERS.md`](https://github.com/bowmark-ai/web/blob/main/PROVIDERS.md). **Bowmark itself has no scheduling, watch, cron or alert primitive — every run is one-shot.** There is no capability in the [library](#no-install-at-all) that polls a page on an interval or pages you on a change; "check this weekly" is always something YOU schedule, calling `POST /v1/run` from your own cron, GitHub Action, or (below) a no-code tool. If you're looking for a built-in recurring watch and not finding one in `GET /v1/library`, that's why — it isn't there yet, and this is the whole workaround. ## Running it on a schedule with no code — Zapier / Make Everything above assumes you're writing the script. If you're not a programmer — you build in Zapier or Make, not a code editor — you can still turn a Bowmark check into a weekly job, because the "script" your automation sends can be one fixed piece of text you paste in once. Nothing here needs a Bowmark connector; it's the same `POST /v1/run` from [No install at all](#no-install-at-all), called by a no-code tool instead of your own code. 1. **Trigger** — "Schedule by Zapier", set to weekly. 2. **Call Bowmark** — a "Webhooks by Zapier: POST" step to `https://api.bowmark.ai/v1/run`, with header `Authorization: Bearer ` and this JSON body (paste it in as-is — the quoted text is the whole "script", not something you edit): ```json { "script": "const { flights } = await bowmark.flights.search({ from: 'OSL', to: 'AMS', depart: '2026-10-01' }); return flights;" } ``` 3. Zapier parses the JSON reply automatically; `result` is the array this run found. 4. **Loop over `result`** with "Looping by Zapier", and inside the loop use Airtable's own **"Find Record"** action (search your table by the listing's URL), then chain Airtable's **"Create Record"** action for when nothing was found. 1. **Trigger** — a "Schedule" module, weekly. 2. **Call Bowmark** — an "HTTP: Make a request" module, `POST` to `https://api.bowmark.ai/v1/run`, header `Authorization: Bearer `, body type JSON: ```json { "script": "const { flights } = await bowmark.flights.search({ from: 'OSL', to: 'AMS', depart: '2026-10-01' }); return flights;" } ``` 3. Make parses the response; `result` is the array this run found. 4. **Iterate `result`** with Make's built-in Iterator module, and per item use Airtable's **"Search Records"** action (by the listing's URL) followed by **"Create a Record"** when the search comes back empty. **That Find-then-Create pair is the entire "only report what's new" logic, and none of it is code** — it's two native Airtable actions per row, run by the automation tool, not by the script. Swap the one-line script for whatever check you're running — `bowmark.providers.finn.search(...)`, `bowmark.read.page(url)` — anything documented on this page returns the same `{ result, status, ... }` shape. You don't need it for the recipe above — Airtable's own Find/Create already tells new from repeat. If you also want a copy of every week's full raw result saved to your Bowmark account, add one line to the script: `await bowmark.files.save({ name: 'check.json', text: JSON.stringify(flights) })`. There's no way to read that file's *content* back inside a later script — `bowmark.files.get(id)` hands back a presigned URL, and a script has no `fetch` to follow it with (see [What is forbidden](#what-is-forbidden)) — so it's a record you'd open by hand or fetch from the automation tool itself, not a mechanism for diffing runs. If the site you're checking needs a login your automation can't provide, store it once and the scheduled script keeps working unattended with no further pauses — see [Storing a login for an unattended script](#storing-a-login-for-an-unattended-script) below. Nothing about that changes when the caller is a Zap or a Make scenario instead of your own code. ### Not everyone wants a schedule — a button someone else can press The recipe above assumes you want this running every week on its own. Sometimes the real requirement is the opposite: someone built the check once, and a **different, non-technical person** needs to be able to re-run that exact same check later — next month, next year — without a terminal, an API key, or the person who built it. Swap the "Schedule" trigger for a manual one; everything after the trigger (the HTTP call, the JSON body, the Airtable Find/Create) is identical. 1. **Trigger** — "Instant" trigger, e.g. "Webhooks by Zapier: Catch Hook", or a form tool like Zapier Interfaces / Google Forms wired to the same Zap. Either way you end up with a URL or button the non-technical person opens whenever they want a fresh check — no schedule involved. 2. Steps 2-4 are the same "Call Bowmark" / parse / loop-into-Airtable steps as the scheduled recipe above. 3. Share the trigger URL (or the form link) with the person who needs to re-run it. They never see the script or the API key — both are already saved inside the Zap. 1. **Trigger** — swap the "Schedule" module for a "Webhooks: Custom webhook" module, or put the scenario behind a Make "On Demand" run (the scenario's own **Run once** button in the Make UI, which anyone with access to that scenario can click). 2. Steps 2-4 are unchanged from the scheduled recipe. 3. For a person who shouldn't need the Make UI at all, front the webhook with a bookmarked link or a one-button page (a Google Form, an Airtable button field configured to hit the webhook URL) so "run the check" is a single click. Both shapes — recurring schedule, and on-demand for someone else to trigger later — reuse the exact same fixed script and the exact same Airtable dedup step. Pick the trigger that matches who needs to press "go" and how often, not whether the rest of the recipe changes. ## Inside an agent — the script string Everything below is the second surface: plain async JavaScript, sent as a string, run in a sandbox with the library bound to a global. This is what an agent's `run` tool sends, and what `run()` above sends from your code. ### The shape of a script Write a plain async **body**, not a wrapping function. ```js const { flights, warnings } = await bowmark.flights.search({ from: "SFO", to: "JFK", depart: "2026-09-01", }); return { flights: flights.slice(0, 3), warnings }; ``` ### What you get | Global | What it is | | -------------- | ------------------------------------------------------------ | | `bowmark` | The library. Already bound — there is no import step. | | `log(...args)` | Records a progress line, returned in `logs`. | | `return` | Whatever you return comes back as `result`, JSON-serialized. | **One script is one live instance per unit, for the whole script.** Two calls to the same provider share its browser, cookie jar and cart; two separate runs do not. So the multi-step flow that needs [`session()`](#several-calls--use-session) from your own code needs nothing here — sequential `await`s are the session. `session()` itself is not a global and cannot be sent as part of a script. Discovery is the other channel. `get_library(...)` inside a script throws `ReferenceError: get_library is not defined`, and `bowmark.get_library(...)` throws `bowmark.get_library is not callable — call a function on a capability`. Read the library **before** you write the script — as the `get_library` MCP tool, or over HTTP with [`GET /v1/library?query=…`](#no-install-at-all). Real control flow is available: `if`, loops, `map` / `filter` / `sort` / `slice`, and `Promise.all` for fan-out. **Every library function is async.** Always `await`. A forgotten `await` returns a pending promise, which serializes to `{}` and looks like an empty result. ### What is forbidden `bowmark` is the **only** I/O. * No `fetch`, no `XMLHttpRequest` * No filesystem, no `process` * No `import`, no `require` The sandbox has hard CPU, memory and wall-clock limits, so keep scripts small and deterministic. No infinite loops. Scripts run off-process in a V8 isolate. A script that reaches for `fetch` does not get a warning — the identifier is not there. ### Composition is the point One script, several calls, combined however the task needs. This is the reason the string surface exists at all: it turns many round trips into one. ```js const dates = ["2026-09-01", "2026-09-02", "2026-09-03"]; const runs = await Promise.all( dates.map((depart) => bowmark.flights.search({ from: "SFO", to: "JFK", depart })), ); return { cheapest: runs .flatMap((r) => r.flights) .sort((a, b) => (a.price ?? 1e9) - (b.price ?? 1e9)) .slice(0, 5), warnings: runs.flatMap((r) => r.warnings), }; ``` Fan out with `Promise.all`, then reduce in the same script. One round trip. ## Reading the response `run` gives you `{ ok, status, result, logs, error, ms, trace }`. The typed client throws instead — this section is about the envelope. **Check `status` before `ok`.** It is `ok` | `error` | `partial` | `needs_user`. ### `partial` — it ran, the answer is narrower than you asked for The script **ran** and `result` is real, but some of what it called never answered. `ok` is still `true`. This is not a failure. | Field | What it tells you | | --------------------- | ------------------------------------------------------------- | | `incomplete.summary` | What happened, in one sentence. | | `incomplete.failures` | Each call that threw, and what the site said. | | `incomplete.degraded` | Each call that answered while reporting its own results thin. | Say so when you present the result. Name what was missed, and never call it complete, exhaustive, or "all" of anything. Check `incomplete.failures[].fixable` before treating it as final. **`fixable: true` means your argument was rejected, not the site** — the error names what that function really takes, so fix it and run again. For anything else, re-running rarely helps. ### `needs_user` — a site needs the user signed in A pause, not a failure, and not something a script edit can fix. 1. `needs` lists the sites; `meta.handoff.url` is a single-use link that expires. 2. Give the user that URL, say which sites it covers, and **wait**. 3. When they say they are done, send the **same script again, unchanged**. Do not retry before then — it will stop in the same place and cost another run. Do not try to sign in yourself, and do not ask the user for a password. In the typed client this arrives as `BowmarkNeedsUserError`, and inside a `session()` the session is still open — open the URL, then call again. ### Storing a login for an unattended script `needs_user` above assumes a person is watching and can click the link right now. A script that runs on a schedule — a cron job, a nightly check, an alert bot — has nobody there. Store the credential once and the same script keeps working every run after that, with no pause. 1. **`list_secrets` and `request_secret` are chat-only tools, not something a script calls.** They exist so the *person* types the value in on their own page — a script never receives it, and there is no `bowmark.request_secret(...)` to call from inside `run()` or `session()`. From the agent's chat: `list_secrets` checks whether a name is already set, and `request_secret({ name, type, hosts })` creates one and returns a single-use link. Give the user that link and wait for them to say they are done. 2. **`bowmark.secret("name")` is how a script references it, forever after.** It resolves to the real value host-side, at call time — the script itself never holds it, and printing the reference (a log line, a returned value) yields `‹secret:acme_password›`, never the credential. That is what makes it safe to leave inside a script a scheduler runs unattended. ```js // Chat, once: request_secret({ name: "acme_password", type: "password", hosts: ["acme.com"] }) // hands the user a one-time link to set it. No script is involved in this step. // Inside the scheduled script, every run after that — no pause, no person required: await bowmark.providers.acme.signIn({ username: "me@example.com", password: bowmark.secret("acme_password"), }); ``` `bowmark.secrets.generate` / `bowmark.secrets.save` are a different case worth telling apart: they are for a credential the *script itself* creates during a run (a sign-up password it picks, an API key a site hands back), not one a person already knows. Use `request_secret` when the login already exists. A stored secret is passed as an **argument** to a typed function, which is why the example above is a `providers.acme.signIn(...)` call. The [browser agent](#when-nothing-else-works-the-browser-agent) takes a plain-language `task` and nothing else — there is no credential or secret option on it today, and a `bowmark.secret("…")` dropped into the task string sends the literal text `‹secret:acme_password›` to the browser, never the value. **So an unattended, scheduled login only works on a site that has a typed provider with a `signIn`.** On a site with no provider, a login inside a browser-agent task comes back as `needs_input` with `kind: "takeover"` and waits for a person to sign in at `watchUrl` — by definition attended. Check [`GET /v1/library?query=…`](#no-install-at-all) for the site before you plan around a stored credential. ## `warnings` is the only thinness signal Some capabilities return `{ rows, warnings }` (flights, hotels, cars); others return a bare array — check the signature. Where there is one, `warnings` names any site dropped from that search, and it is the **only** signal that an answer is thin rather than complete. A result with warnings you did not read is a result you cannot describe honestly. ## Providers vs capabilities ```js // Capability — fans out across every site that can serve the task, // routes around the ones that fail. The default. const { flights } = await bowmark.flights.search({ from: "SFO", to: "JFK", depart }); // Provider — one specific site, when you want THAT site's answer. const rows = await bowmark.providers.kayak.search({ from: "SFO", to: "JFK", depart }); ``` Prefer the capability unless you have a reason to pin a site. The same two tiers exist in both surfaces, spelled identically. ## No provider for a site? Use `read.page` `get_library` came back empty for a site — no capability, no provider. That does not mean Bowmark cannot reach it: `bowmark.read.page(url)` (and `read.pages` for several URLs at once) fetches any page as a plain GET and escalates to a real browser only if the page forces one. It is the documented fallback for exactly this case, not a workaround. **Call it through `run()` — never `session()`, and never the bare top-level `bowmark` client, which opens a session internally even for one call.** Its reach rung is decided per call rather than declared statically, so both refuse it with `code: "rung_undeclared"` — see [What it throws](#what-it-throws). ```js await run(` const page = await bowmark.read.page("https://example.com/blog/post-1"); // \`content\` is markdown by default — pass { format: "html" } if you want the raw bytes. return { title: page.title, preview: page.content.slice(0, 200) }; `); ``` Check more than `content` before you trust it. The result also carries: | Field | What it tells you | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ok` | `false` when the read failed **or** came back as a wall, a login page or an empty shell rather than the page — under every `strategy`, including `"browser"` | | `warnings` | Why a read is thin: a shell, an uncleared bot wall (named), a redirect to a different page, a budget that ran out. Empty only when nothing was dropped | | `url` / `requestedUrl` | Where the read ended up versus what you asked for. A site that redirects you to its homepage is flagged in `warnings` too | | `status` | The HTTP status; `0` means the request never completed, and `error` says why | | `servedBy` / `escalated` / `escalationReason` | Which leg produced `content`, whether the plain GET was rejected for a browser's, and why a browser was needed | | `wall` | The bot wall the page is behind and whether it was cleared — `{ vendor, cleared }`, or `null` | | `truncated` | `true` when `content` was cut at `maxChars` — see below | **`content` is capped at 200,000 characters by default, and `maxChars` raises it.** A page longer than the cap comes back cut, with `truncated: true`, a `content cut at 200000 chars (page had 563960)` line in `warnings`, and the run's status `partial`. That is common with `format: "html"`, where a page's raw markup is often several times its text. It does not mean the format failed — ask for more: ```js const page = await bowmark.read.page(url, { format: "html", maxChars: 600000 }); ``` `maxChars` bounds what is returned, not the memory spent loading the page, and a run has 1024MB for its whole script. One large page is fine; on a `read.pages` batch every page's content is held until the batch returns, so keep `maxChars × number of pages` under about 500MB — or read the big pages in separate `run()` calls. Full walkthrough: [How it works § No provider? Read any page directly](/docs/how-it-works#no-provider-read-any-page-directly). ## When nothing else works: the browser agent The page needs a form filled, a wizard driven, a login walked through. `bowmark.browser_agent` hands the task to a hosted browser agent that does it for you, and returns a watch link a person can open to follow along or take over. There is no flat per-run price — a run is metered on the model turns it takes plus the browser time it holds open, itemized on your [billing dashboard](https://bowmark.ai/dashboard/billing) as `browser_agent.vendor`. [Estimating costs](/docs/pricing#hosted-browser-agents) has the per-minute browser rates and a worked example so you can size a run before you start it. Four things about it are not like the rest of the library, and each costs build time when you meet it in a stack trace — or in the generated type declarations — instead of here. **One: it runs through `run()` only.** A `session()` call — and a bare `bowmark.…` call, which opens a one-shot session for you — is refused with `code: "run_only"`. A browser agent session is owned and billed by the run that started it, and only the sandbox binds that ownership. **It is never a problem with your API key**, which is the wrong conclusion the old error invited. ```ts // ✗ refused with code: "run_only" — and your key is fine await session(async (bm) => bm.browser_agent.start({ task })); // ✓ const { result } = await run(`return bowmark.browser_agent.start(${JSON.stringify({ task })})`); ``` **Two: poll it from LATER runs, never inside one.** A single `run()` call is killed at **90 seconds** of wall clock. A script that starts the browser agent and immediately loops on `status()` in the same run will hit this ceiling on the second poll. Each call to `status()` blocks for up to 60 seconds (the `waitMs` parameter), so two `status()` calls back-to-back consume 120 seconds — past the ceiling before the browser agent has had time to work. Start in one `run()`, then poll from separate later `run()` calls — each gets its own 90-second budget. **Three: a normal task takes a handful of polls, and a repeated step name is not a stall.** Most tasks reach `idle` in **1-4 poll cycles — roughly 1-3 minutes total**, not one. Each poll's `steps` shows the agent's current activity, and the *same* step name showing up on back-to-back polls ("Read the page", "Considering interaction setup") is normal — multi-step sites routinely need several passes at the same kind of action before they resolve. **It is not evidence of a hang.** Keep polling until `status` is `idle` or `needs_input`; stopping on a status plateau is the single most common way to abandon a run seconds before it would have returned the answer. **However: Bowmark itself cuts off a turn that runs too long, so you rarely have to.** A single turn stuck past a few minutes with no result — drifting instead of converging, the way a wedged agent does — is cancelled automatically and `status()` reports `failed` with `error` naming what it was last doing. The session stays open: `send()` a narrower instruction to try again in the same browser, or `stop()` it. This is a fixed Bowmark-side ceiling, not something you configure — there is no way to raise or lower it per call. **`timeoutMs` on `start`, `status` and `send` is a different, narrower thing: how long we wait for OUR OWN request to Browser Use's API to answer** (create the run, poll it, send a follow-up), default 30 seconds, capped at 55. It does **not** bound how long the agent works on your task — raising it does not buy the agent more time, and it cannot be used to make a run stop sooner than Bowmark's own ceiling above. If a task consistently reads `failed` from the automatic cutoff, that is a sign the task itself needs to be narrower (fewer steps, a more specific instruction), not a limit to raise. The default budget is **2 USD** (`maxCostUsd`), which at typical browser-agent rates ($0.02–$0.25 per run) translates to roughly 5-100 runs before the browser stops. **Field-by-field verification:** The `result` field contains a summary of what was completed. For write operations (form submissions, uploads), the summary describes what was filled in and includes any confirmation text from the site. If you need to verify individual fields were filled correctly, include that detail in your original task description — for example, "After filling the healthcare form, describe each field value you entered so I can verify correctness." Start in one run, keep the id, and poll from as many later ones as the task needs: ```ts // run 1 — start it, and show your user the watch link const started = await run(`return bowmark.browser_agent.start(${JSON.stringify({ task })})`); const { id, watchUrl } = started.result as { id: string; watchUrl: string }; // run 2, 3, 4 … — one round trip each, and no run holds the browser open waiting for (;;) { const poll = await run(`return bowmark.browser_agent.status(${JSON.stringify(id)}, { waitMs: 60000 })`); const st = poll.result as { status: string; question?: string; result?: unknown }; if (st.status === "needs_input") { /* ask your user, then browser_agent.send(id, answer) */ break; } if (st.status === "idle") { await run(`return bowmark.browser_agent.stop(${JSON.stringify(id)})`); break; } } ``` **Four: it has no stored credential, so it cannot do an unattended login.** `start()` takes `task` and a few knobs (`backend`, `model`, `maxCostUsd`, `proxyCountry`, `timeoutMs`) — there is no credential or secret field, and [`bowmark.secret("…")`](#storing-a-login-for-an-unattended-script) cannot be reached from a task string: interpolating one yields the literal placeholder `‹secret:name›`. When a task hits a login, a CAPTCHA, or any other challenge only a person can solve, the agent returns `needs_input` with `kind: "takeover"` and waits for a person to act at `watchUrl`. **A stored login is a typed-provider feature**, so a scheduled script that has to sign in needs a site the library already covers — the browser agent is the attended fallback, not an unattended one. **This holds on every run, not only the first.** Nothing about a login is remembered between sessions — a script that walked a person through signing in yesterday still needs a person at `watchUrl` today, on the identical site, because there is no stored login to replay. If your plan for a repeat script is "reuse the login from last time", that plan does not work on `browser_agent`; it only works on a site with a typed provider and a `signIn` you can call with `bowmark.secret()`. They are two different browsers with no shared cookie jar. The browser agent drives a vendor-hosted browser (Browser Use) that `ctx.browserAgents` owns and bills to the run that started it; `read.page` opens a browser from Bowmark's own pool. Nothing carries a cookie between them — not across separate `run()` calls, not inside one `run()`, and not through `session()`, because `read.page` refuses `session()` outright (`code: "rung_undeclared"`, see [What it throws](#what-it-throws)) for the same reason `browser_agent` does (`code: "run_only"`). **For hundreds of pages behind one login, the cheap path is a typed provider's own `session()`** — sign in once with `providers..signIn(...)` (using a stored `bowmark.secret()` for an unattended script), then make repeated typed calls inside that same `session()`. That reuses one cookie jar across every call, at typed-provider rates. **If the site has no typed provider, there is no cheap path.** Every page that needs the login goes through `browser_agent` again, at its [per-run rate](/docs/pricing#hosted-browser-agents) — `read.page` cannot inherit that login, so it cannot substitute for the agent on pages that need one. An open session keeps a real browser running — it costs money until Bowmark closes it after 20 idle minutes, and it counts against your 3-session concurrent limit the whole time. `bowmark.browser_agent.list()` shows what you are holding. **Concurrent sessions:** Your account can hold up to **3 open browser-agent sessions at the same time**. If you try to start a 4th session while 3 are still running, the call throws and you must `stop()` one first. Use `bowmark.browser_agent.list()` to see what you're holding. **Monthly spend cap:** Each account has a monthly spend cap for browser-agent usage (defaults to $2 per run, configurable up to $25). When your account approaches its monthly limit, new browser-agent tasks are refused with an error message directing you to adjust your billing settings at bowmark.ai/dashboard/billing. **No rate limit on calls:** You can start as many browser-agent tasks as you want (one at a time, within the 3-session concurrent limit). There is no per-minute request limit — only the concurrent session limit and the monthly billing cap. **Cost estimation:** Each browser\_agent call costs **$0.02–$0.25 per run**, depending on task complexity and duration. Typical tasks run 1-3 minutes and cost $0.02–$0.10. See [Pricing § Hosted browser agents](/docs/pricing#hosted-browser-agents) for the full cost breakdown and how to estimate your costs at scale — particularly useful if you're running hundreds or thousands of tasks. The 90-second ceiling is not specific to the browser agent: **it is the wall-clock limit on any single `run()`**, and the reason a long job belongs in several runs rather than one.