Scripting
Write against the library from your own Node or Python program, or as a script string inside an agent. Both surfaces, and when each is right.
Reading this with an agent?
curl -s https://bowmark.ai/docs/scripting.md. The whole site as one file: https://bowmark.ai/llms-full.txt. Index of every page: https://bowmark.ai/llms.txt.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 git+https://github.com/bowmark-ai/web.git#subdirectory=python | 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.
For your own code, use session() for most tasks. It keeps state across multiple
calls (a shopping cart, a search result, a logged-in session), which is what real scripts
need. run() is the agent interface — a string the agent composes on the fly — and it
exists for that use case, not as a primary surface for human-written code.
From your own code
Install it
npm i @bowmark/web@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.
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=…, or the
CAPABILITIES.md and
PROVIDERS.md lists. What the
package gives you is bowmark.<capability>.<fn>() and bowmark.providers.<site>.<fn>().
It ships TypeScript source, so plain `node` cannot run it
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:
npx tsx app.ts # or: bun app.tsThe examples on this page use top-level await, which needs ESM. In a project from
npm init -y (CommonJS by default), npx tsx app.ts fails with Top-level await is currently not supported with the "cjs" output format. Add "type": "module" to
package.json, name the file app.mts, or wrap the calls in an async function main().
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 instead.
pip install git+https://github.com/bowmark-ai/web.git#subdirectory=python
pip install git+https://github.com/bowmark-ai/web.git#subdirectory=python-stubsRequires Python 3.11 or newer. Install from GitHub: the bowmark-web and bowmark-web-stubs names on PyPI are empty v0.0.0 placeholders. MIT, zero runtime
dependencies; urllib and json are the whole transport.
The second package is the types.
PEP 561 requires a stub distribution to be named
<pkg>-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
import { bowmark } from "@bowmark/web";
const { tracks } = await bowmark.music.search("aphex twin", 3);
console.log(tracks[0].title);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 and create a key at
bowmark.ai/dashboard/keys. With no key the first
call throws code: "no_api_key" and sends nothing.
Several calls — use session()
bowmark.<unit>.<fn>() opens a fresh instance for that one call and closes it. Two of
them get two separate instances and two cookie jars, so a cart the first filled does not exist
for the second.
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
});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 1Reach 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.
`session()` is a package export — it cannot go inside a script string
It is import { session } from "@bowmark/web", running in your process. Put it in a
script you POST to /v1/run and you get ReferenceError: session is not defined — the sandbox's globals are
bowmark, log, run, URL, URLSearchParams 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 instance, 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 and set it |
merchant_verification_failed | A DIFFERENT failure from a bot wall: browser_agent reached a live page but could not confirm it is the merchant you named — it only checks this on a task that names a specific merchant to buy, pay, order or check out from. A separate blocked: … error (not this one) is what a bot wall (WAF, Cloudflare, anti-scraping) reports. | Make the task name the merchant's own domain explicitly. If the site is actually blocking bots you will see blocked: …, not this error — that one is where a provider or read.page is worth trying instead. |
rung_undeclared | The unit decides per call how to load each page, so session() cannot set it up ahead of time. read.page and read.pages — the documented fallback when a site has no provider — are the units that do this. | Call it through run() instead: await run("const page = await bowmark.read.page(url); return page.content;") — a run decides what each page needs as it goes |
run_only | The unit is served only by run(), 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. 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.
Two guards run before the request, not after
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.
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"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 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.
One run is capped at 90 seconds of wall clock
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.
That is the most any run gets. A script that makes only a few quick calls is given less, sized to the calls it makes. If one of those is cut off early the error says so, and sending the same script again gives it the full time.
Fan-out is not the only way to hit it — chaining two slow calls in one run is the other,
and Walmart is the concrete case. bowmark.providers.walmart.search and
listSellerOffers each need a real browser and commonly take 20-50 s alone. Call search and
then look up offers for a result in the same run, the way you naturally would, and the pair
alone can pass 90 s with no partial result. Split a Walmart search → listSellerOffers
lookup into two separate run() calls.
Too many runs at once, and a busy platform
One account can have a limited number of runs queued or running at the same time
(currently 16). Past it, POST /v1/run answers 429 with a Retry-After header and a
meta.concurrency object (limit, inFlight, retryAfterSeconds). Nothing started and
nothing was charged, so wait and send fewer at a time rather than repeating the whole batch.
The 90 s is counted from when your request arrives, so a run that waited for a free slot has
less of it left: queuedMs in the response says how long it waited (it appears when that
was a second or more). If no slot frees up within 45 s, the error says either
every slot is busy (a queue: send fewer) or the executor service is down (an outage on
our side). In both the run did not start, so sending the same script again later is safe.
No install at all
The client is a convenience over two HTTP endpoints. Anything that can POST JSON can use Bowmark with nothing installed.
If you need account billing or spend rather than a capability, do not search the library for
“pricing”: read it directly from GET https://api.bowmark.ai/v1/usage with the same Bearer key
you use for POST /v1/run.
curl -s https://api.bowmark.ai/v1/usage \
-H "Authorization: Bearer $BOWMARK_API_KEY"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();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"])curl -s "https://api.bowmark.ai/v1/library?query=flights" \
-H "Authorization: Bearer $BOWMARK_API_KEY"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 same call reading one page with read.page,
and the response it returned on 2026-10-27, unedited — every field name below is real:
curl -s -X POST https://api.bowmark.ai/v1/run \
-H "Authorization: Bearer $BOWMARK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"script":"const p = await bowmark.read.page(\"https://example.com\"); return { ok: p.ok, title: p.title, chars: p.chars, warnings: p.warnings, start: p.content.slice(0, 60) };"}'{
"ok": true,
"status": "ok",
"result": {
"ok": true,
"chars": 156,
"start": "This domain is for use in documentation examples without nee",
"title": "Example Domain",
"warnings": []
},
"logs": [],
"error": null,
"ms": 8152,
"runId": "8fddd5dd-7b95-421d-9f88-a36b485e4341"
}The outer ok / status say the script ran; the inner result.ok is read.page's own
verdict on the page (false on a wall, a login page or an empty shell). Every field is in
Reading the response.
Python stdlib `urllib` has a known blocker
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 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 and
PROVIDERS.md.
report — the tool this page tells you to call when a result looks wrong or a site
isn't covered — has no /v1/* twin. It only exists as an MCP tool, so a program
calling the two endpoints above has no REST verb for it. Call it over /mcp instead,
with the same Bearer key — it's plain JSON-RPC, not a protocol that needs an SDK:
curl -s -X POST https://api.bowmark.ai/mcp \
-H "Authorization: Bearer $BOWMARK_API_KEY" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"report",
"arguments":{"report":"what failed or what is missing, <=4000 chars",
"runId":"optional — a prior run id you are reporting on"}}}'Leaving out runId is how you ask Bowmark to cover a site it doesn't yet — the same call,
naming the site and what you need in the report text instead of a prior run.
Estimating what a script will cost at volume
Before you schedule a script or commit to it in an integration, you need a real per-run cost.
The published per-resource rates can be wrong for your specific sites and script; use GET /v1/usage
to query your account's actual spend and patterns.
import fetch from "node-fetch";
async function estimateCost() {
// Before you run the script, note your current usage
const before = await fetch(
"https://api.bowmark.ai/v1/usage?since=2026-01-01",
{
headers: {
authorization: `Bearer ${process.env.BOWMARK_API_KEY}`,
},
}
).then((r) => r.json());
// after.usage.estimated_cost
// Run your script, then check again an hour later
const after = await fetch("https://api.bowmark.ai/v1/usage", {
headers: {
authorization: `Bearer ${process.env.BOWMARK_API_KEY}`,
},
}).then((r) => r.json());
const costPerRun = after.usage.estimated_cost - before.usage.estimated_cost;
console.log(`Actual cost per run: $${costPerRun.toFixed(2)}`);
}import json, urllib.request, os
api_key = os.environ["BOWMARK_API_KEY"]
# Before the script
before = urllib.request.urlopen(
urllib.request.Request(
"https://api.bowmark.ai/v1/usage?since=2026-01-01",
headers={"authorization": f"Bearer {api_key}"},
)
)
before_cost = json.load(before)["usage"]["estimated_cost"]
# After the script runs
after = urllib.request.urlopen(
urllib.request.Request(
"https://api.bowmark.ai/v1/usage",
headers={"authorization": f"Bearer {api_key}"},
)
)
after_cost = json.load(after)["usage"]["estimated_cost"]
cost_per_run = after_cost - before_cost
print(f"Actual cost per run: ${cost_per_run:.2f}")# Before running your script
curl -s "https://api.bowmark.ai/v1/usage?since=2026-01-01" \
-H "Authorization: Bearer $BOWMARK_API_KEY" | jq '.usage.estimated_cost'
# After running your script (wait an hour for the data to settle)
curl -s "https://api.bowmark.ai/v1/usage" \
-H "Authorization: Bearer $BOWMARK_API_KEY" | jq '.usage.estimated_cost'This is the only reliable way to estimate costs at volume. The published rates are accurate
for individual calls, but your script's specific mix of sites, the number of retries, and any use
of browser_agent all affect the real figure. One script that hits five 30-second browser renders is
not the same cost as one that reads five static pages in 2 seconds each.
Bowmark itself has no scheduling, watch, cron or alert primitive — every run is
one-shot. There is no capability in the library 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 GitHub Actions
If your code already lives on GitHub, a scheduled workflow is the simplest free scheduler. The bowmark-watch template does the whole thing: it runs a Bowmark script every day, compares the rows with the last run, and opens an issue when something is new or different.
- Click Use this template on github.com/bowmark-ai/bowmark-watch.
- In your copy, add a repository secret named
BOWMARK_API_KEY(Settings > Secrets and variables > Actions). - Edit
watch.jsto return the rows you care about, each with a stablekey. - Run Actions > watch > Run workflow once to record the starting point.
To wire it into a workflow you already have, the step is one call to POST /v1/run:
on:
schedule:
- cron: "0 14 * * *" # every day at 14:00 UTC
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Run a Bowmark script
env:
BOWMARK_API_KEY: ${{ secrets.BOWMARK_API_KEY }}
run: |
curl -s https://api.bowmark.ai/v1/run \
-H "authorization: Bearer $BOWMARK_API_KEY" \
-H "content-type: application/json" \
-d '{"script":"return await bowmark.read.page(\"https://example.com\")"}' \
> result.jsonEach scheduled run is one Bowmark run, billed like any other.
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, called by a no-code tool instead of your own code.
- Trigger — "Schedule by Zapier", set to weekly.
- Call Bowmark — a "Webhooks by Zapier: POST" step to
https://api.bowmark.ai/v1/run, with headerAuthorization: Bearer <your BOWMARK_API_KEY>and this JSON body (paste it in as-is — the quoted text is the whole "script", not something you edit):{ "script": "const { flights } = await bowmark.flights.search({ from: 'OSL', to: 'AMS', depart: '2026-10-01' }); return flights;" } - Zapier parses the JSON reply automatically;
resultis the array this run found. - Loop over
resultwith "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.
- Trigger — a "Schedule" module, weekly.
- Call Bowmark — an "HTTP: Make a request" module,
POSTtohttps://api.bowmark.ai/v1/run, headerAuthorization: Bearer <your BOWMARK_API_KEY>, body type JSON:{ "script": "const { flights } = await bowmark.flights.search({ from: 'OSL', to: 'AMS', depart: '2026-10-01' }); return flights;" } - Make parses the response;
resultis the array this run found. - Iterate
resultwith 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.
No Airtable? A script can remember yesterday's results itself
Airtable's Find/Create is one way to tell new from repeat. The other needs nothing
outside Bowmark: save today's result to your account with bowmark.files, and have the
next run read yesterday's back with bowmark.files.get(id, { content: true }), which
returns the file's metadata plus its text. bowmark.files.list() comes back newest
first, so the previous snapshot is the first match by name:
const prev = (await bowmark.files.list()).find(f => f.name === 'check.json')
const seen = prev ? JSON.parse((await bowmark.files.get(prev.id, { content: true })).text) : []
const fresh = flights.filter(f => !seen.some(s => s.url === f.url))
await bowmark.files.save({ name: 'check.json', text: JSON.stringify(flights), keepFor: '30d' })
return freshReading content back is capped at 25 MB per file, the same ceiling as saving. Plain
bowmark.files.get(id) without { content: true } still returns only metadata and a
signed url. This works the same whether the script is run by a schedule, a Zap, or
someone pasting one prompt into chat each morning.
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 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.
- 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.
- Steps 2-4 are the same "Call Bowmark" / parse / loop-into-Airtable steps as the scheduled recipe above.
- 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.
- 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).
- Steps 2-4 are unchanged from the scheduled recipe.
- 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.
Storing data across runs with bowmark.files
bowmark.files saves files to your account during a run and retrieves them later. Use it to track state across scheduled runs without needing an external service like Airtable.
// Save a file
await bowmark.files.save({
name: "yesterday-results.json",
text: JSON.stringify(results),
keepFor: "7d" // optional: expires in 7 days
});
// List all files in your account (newest first)
const files = await bowmark.files.list();
const lastRun = files[0]; // { id, name, bytes, contentType, expiresAt, url }
// Retrieve a file's content
const file = await bowmark.files.get(lastRun.id, { content: true });
const data = JSON.parse(file.text);
// Get just metadata (no content) — useful for checking a file exists
const metadata = await bowmark.files.get(lastRun.id);
const downloadUrl = metadata.url; // presigned, valid 1 hourImportant notes:
- Files are stored in your account, not per-session —
bowmark.files.list()returns all files you have saved across all runs and scripts. - The
urlis presigned and expires in 1 hour — it's safe for immediate sharing but not for long-term bookmarks. Usebowmark.files.get(id)orbowmark.files.list()to retrieve the file id, then callbowmark.files.url(id)to regenerate a new presigned URL. - Content reads are capped at 25 MB per file, same as saves — if you need larger files, save multiple smaller ones.
- Use
keepForto auto-expire files — otherwise they stay until you manually delete them. Formats like"7d","1h", or"30m"are supported.
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.
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. |
run(fn) | Calls fn and returns what it returns. Here only so wrapping the whole body in run(async () => { ... }) — easy to do by analogy with the run tool that sent this script — works rather than throwing ReferenceError: run is not defined. Write a plain body instead; you do not need it. |
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 state, cookie jar and cart; two separate runs do not. The instance is per
unit, not per script: read.page, web_form_fields and browser_agent are each their own
browser with their own cookies, so a form fillForm submitted is not open in a later
read.page — read what the form returned off fillForm's own resultContent instead. So the multi-step
flow that needs session() from your own code needs nothing
here — sequential awaits are the session. session() itself is not a global and cannot be
sent as part of a script.
`get_library` is NOT one of them
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=….
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, noXMLHttpRequest - No filesystem, no
process - No
import, norequire
The sandbox has hard CPU, memory and wall-clock limits, so keep scripts small and deterministic. No infinite loops.
This is a real boundary, not a lint rule
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.
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 an envelope — ok, status, result, logs, error and ms are
always there; a handful of other fields appear only when they are relevant. There is no
trace on this envelope — a trace is the provider fan-out beneath a capability, internal
structure you cannot act on, and it stays off the wire (run-envelope.md in the repo, for
anyone reading the source). The typed client throws instead of returning this — this
section is about the envelope either way sees underneath.
Check status before ok. It is ok | error | partial | needs_user.
Every field, and when it shows up
| Field | Present when | What it is |
|---|---|---|
ok | always | true for ok/partial, false for error/needs_user |
status | always | ok | error | partial | needs_user |
result | always (null on error/needs_user) | whatever your script returned, JSON-serialized |
logs | always ([] if you called no log(...)) | your log(...) lines, in order |
error | always (null on ok/partial) | why the run failed, written for you to read |
ms | always | how long the run itself took, not counting any time queued |
runId | once the run was admitted to the queue | pass it back to a later run({ runId }) or to report |
queuedMs | only when queueing took a second or more | time spent waiting for a free executor, not included in ms |
incomplete | only on status: "partial" | summary, failures[], degraded[] — see partial above |
emptyHanded | only when your script returned nothing while its own calls returned rows | names the calls and the label values you likely need to match on |
notes | only when a call left a non-verdict caveat | how an answer was reached, or one caveat for using it — never something missing (that's warnings / incomplete) |
needs | only on status: "needs_user" | which sites need a human; meta.handoff.url is the link |
secretNeeds | only on a needs_user caused by a stored secret going stale | { secret, reason, expiredAt? } |
savedSecrets | only when the script stored a secret this run | { name, type, viewUrl } — relay the link, never the value |
savedFiles | only when the script called bowmark.files.save | { id, name, contentType, bytes, url, expiresAt } |
meta | only when there is something to carry | handoff, wwwAuthenticate, accountRequired, concurrency, report |
notice | only when the account has not accepted the current Terms/Privacy Policy | a notice to relay to the account owner — independent of whether the run itself succeeded |
That is the complete advertised shape (RUN_ENVELOPE_SHAPE in the API source) — a strict
MCP host discards the whole result if a field outside this list shows up, so nothing else
rides along.
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.
When a call fails, read incomplete.failures[] to decide what to do:
| Field | What it tells you | How to branch on it |
|---|---|---|
fixable | true means your argument was wrong, not the site. The error names what the function takes. | Check fixable first. If true, fix the call and retry immediately. |
egressFailure | If present, names which side failed: "client" (your script), "internal" (Bowmark's network), "server" (the site or vendor). Omitted means neither side reports a failure class. | When fixable is false or absent, check egressFailure to decide whether the issue is yours, Bowmark's, or the site's. |
retry | Advice from the failing call on whether trying again will help: "now" (a fresh attempt works immediately), "later" (after a wait), or "no" (the same call fails the same way). Omitted means the call left no advice. | Check retry alongside the error. On "no", don't retry. On "later", wait before trying again. On "now" or absent, retrying is safe to try. |
needs_user — a site needs the user signed in
A pause, not a failure, and not something a script edit can fix.
needslists the sites;meta.handoff.urlis a single-use link that expires.- Give the user that URL, say which sites it covers, and wait.
- 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.
This works only on a site with a typed provider that has login(). A site with no typed
provider has no unattended login today: read.page cannot send your session cookie (its
headers: true option returns the response headers, it does not set request headers),
and browser_agent forgets every login between runs. Check coverage first with
GET /v1/library?query=<hostname>.
list_secretsandrequest_secretare 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 nobowmark.request_secret(...)to call from insiderun()orsession(). From the agent's chat:list_secretschecks whether a name is already set, andrequest_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.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.
// Chat, once: request_secret({ name: "letterboxd_password", type: "password", hosts: ["letterboxd.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.letterboxd.login({
username: "my_handle",
password: bowmark.secret("letterboxd_password"),
});
// Later calls in the SAME script reuse that sign-in. No session() is needed inside one run.
const me = await bowmark.providers.letterboxd.getOwnAccount();
return me.username;login() is not in a provider's function list, and that is expected. Bowmark supplies it
for every provider that can sign in, so you won't find it in get_library's signatures. On a
provider that cannot sign in, login() is refused as an unknown function. Call login() and the
signed-in calls in one script, or inside one session() from
your own code.
Two-factor: if the site asks for an authenticator-app code (TOTP), store the app's
setup key (the base32 seed, or its otpauth:// link) as a secret too, and pass it as
totpSeed: bowmark.secret("acme_totp_seed") beside username and password in
bowmark.providers.<site>.login({ … }), on a provider that supports signing in. Bowmark works out the
current code at sign-in time, so the script still runs with nobody there. A code you already
have goes in totpCode instead. A code the site texts or emails, or a "tap approve on your
phone" prompt, cannot be stored — that sign-in needs a person each time.
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.
This needs a typed provider — it does not work with the browser agent
A stored secret is passed as an argument to a typed function, which is why the example
above is a providers.acme.login(...) call. 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
login(). 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=… for the site before you plan around a
stored credential.
Is there anything Bowmark won't do — scraping personal data, bulk contact harvesting?
Yes. Bowmark refuses some kinds of request outright, whoever asks and whatever the account:
- collecting personal data about people in bulk: lists of names with phone numbers or emails, every member of a directory, people-search lookups
- finding or tracking down a private person
- getting into an account that is not yours, or past someone's sign-in or second factor
- getting around a site's own blocks or rate limits, or sniping limited stock
- moving money
- sending messages in bulk
- exploiting a site or its users
- buying regulated goods
Public business information is fine: a shop's prices, a company's published contact page, the opening hours of a business.
A refused run comes back as status: "error" with error starting "Refused by Bowmark's
safety screen", followed by the category it fell into. It is not a site error and not a billing
problem. Nothing ran and nothing was charged. A get_library query in one of these categories
answers with the same sentence. Rewording the same request will not change the answer, because
the categories describe what Bowmark will not do, not a particular phrasing. If you believe a
legitimate request was refused, report it with report and say what you were trying to do.
Is it the site, or is it Bowmark?
Read error first. A failed read.page puts the reason there, not in warnings. When
Bowmark can tell the fault is the site's own, it says so: this domain does not resolve
means the hostname does not exist (check the spelling, or the business has moved), and a
TLS-certificate sentence means the site serves a broken certificate. Both end with
"retrying with a different egress option will not help".
Otherwise, a call that never reached its site fails the same way whether that one site is refusing
us or Bowmark's own network is having trouble: status: 0 from read.page ("the request
never completed (dns, timeout or refused)"), HTTP 0 from a provider, or a network error
such as net::ERR_TUNNEL_CONNECTION_FAILED. The message cannot tell you which. A read of
a page that never blocks anyone can:
const probe = await bowmark.read.page("https://example.com");
return probe.status;200— Bowmark is reaching the web, so the problem is the site you were calling: it is refusing us or is down itself. Retrying straight away rarely changes that.0as well — the problem is on Bowmark's side, not the site's, and nothing is wrong with your script. Try again in a few minutes. Check Bowmark's status page to see whether an outage is known and being fixed.
What to do when a site blocks Bowmark
A site returns ok: false (on read.page) or blocked: ... (on browser_agent), and read.page says the site is refusing the request (status: 0, a 403, or error: "blocked..."). This means the site itself is blocking access.
How to tell if a site is truly blocked:
read.pagewithegress: "default"returnedok: falsewith a wall — try once more withegress: "static-residential"($0.0001 per request). If that is also blocked, continue to step 2.- Both reads came back blocked — the site is refusing Bowmark's addresses outright.
- Nothing else works.
browser_agentis a different network and is worth one try, but if it also fails witherror: "blocked: ...", the site is refusing every route Bowmark has.
When a site truly blocks Bowmark:
- This is the site's choice, not a problem with your script. You cannot argue with a block on the client side.
- If the information is publicly available, check whether the site publishes it elsewhere: the manufacturer's own site, another retailer, an API, a data partner.
- If you need that specific site and the block is a temporary anti-bot measure (common on high-traffic sites), try again in an hour — Bowmark's IP pool rotates constantly.
- If the block is permanent (a policy against automated access), Bowmark cannot read that site, and you will need an alternative source or a different approach.
It is NOT a problem if:
- You got a CAPTCHA —
read.pagesolves the common ones before it answers. - You got a login page — use
browser_agentfor a one-time interactive task, or call a capability /read.pagefrom a scheduled script if the site has no actual login protecting the data. - The page returned an error (500, 502) — that is the site having trouble, not blocking you. Retry in a moment.
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
// 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
Public pages only
read.page reads what a signed-out visitor sees. It cannot sign in, so on a page behind a
login you get the sign-in page back, not your data. For a login-gated page, use a typed
provider with a stored login, or the
browser agent.
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) reads any page — fast where the page allows it, with a fuller load only where the
page needs one. It is the documented fallback for exactly this case, not a workaround.
When a page is behind a CAPTCHA, read.page tries to solve it first — the kinds it solves, and
what each solve costs, are under Captcha solves. One it
cannot clear comes back as ok: false with wall: { vendor, cleared: false } and a warning
naming it. read.page never pauses for a person; for a page that needs one, use
browser_agent, which returns needs_input.
Large booking, travel and marketplace sites are often behind a bot wall — Cloudflare,
DataDome, Akamai — and Bowmark cannot tell you in advance whether a given site is. The first
read.page says so (ok: false, wall: { vendor, cleared: false }). Retry it once, with
egress: "static-residential"; if that is walled too, stop retrying and move to the
browser agent. It is a different programming
model: billed per task, asynchronous (start() returns an id, you poll status() and call
stop()), and one task at a time, so it cannot run as a schedule or a standing watch. The full
ladder is under Does Bowmark get through Akamai or Cloudflare bot walls at
all?.
Don't know which page on the site to read? bowmark.read.urls(url, { pathPrefix }) lists
a site's pages from its sitemap and internal links — each one { url, source, depth, lastmod }, no title or description — and bowmark.search.web(query) searches the open web
instead of one site, returning { source, rank, title, url, snippet, published } rows. Use
read.urls with a pathPrefix to narrow a big site to the section you actually want (an
announcements or press page), and search.web when you don't know which site has it at all.
Call it through run() — never session(), and never the bare top-level bowmark
client. Two different things, both refused for the same reason:
- The top-level client is the plain
bowmark.<unit>.<fn>()call from One call —bowmark.music.search(...), with nosession()wrapped around it. It still opens a session internally for that one call, the same as callingsession()yourself for a single step. session()is the export described above that holds one instance open across several separate calls in your own control flow.
Both commit to how a page will be loaded (plain fetch vs. a real browser) before the call is
made. read.page decides that per call instead, once it has seen the page, so neither can
set it up ahead of time — both refuse it with code: "rung_undeclared". See What it
throws.
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) };
`);Every field on page, and when each one is set: What read.page returns.
Three things worth knowing before you hit them as errors instead:
- Does it run JavaScript? Yes, when the page needs it.
strategy: "auto"(the default) tries a fast fetch first and escalates to a full browser load if the page comes back as a JS shell; force the browser load upfront withstrategy: "browser". See Controlling how the page loads below. If the results you want only appear after a form is submitted — not just after a page render — that is not something aread.pageload can trigger; useweb_form_fields.fillForm(..., { submit: true })orbrowser_agentinstead. - Does it scroll to load more results? Once. A browser load scrolls the page a
single time so lazy-loaded content (images, a product grid that fills in as you scroll)
appears, then reads it. It does not keep scrolling an infinite list or click "Load
more", so an infinite-scroll results page gives you its first batch only. For the rest,
look for the request the page makes to fetch the next batch (often a JSON url with a
page or offset parameter, which
read.pagereturns parsed injson), or usebrowser_agentand say in the task how many results you need. - Can it send a POST? No —
read.pagealways issues aGET. There is nomethodorbodyoption. A site whose data loads via a POST (an AJAX search, a form action) needsweb_form_fieldsorbrowser_agentto drive it instead. - Can it give me structured fields instead of HTML I parse myself? No —
read.pageonly returns markdown, text or HTML; parsing it into fields (a name, a price, a rating) is on you. A site with a Bowmark provider returns typed fields directly. For a site with neither,browser_agent'soutputSchemareturns validated JSON, but it is billed per task rather than free like a read. - How long can one read take?
timeoutMsis the budget for the whole read, end to end — default 45,000ms, and 55,000ms is the hard ceiling whatever you pass, deliberately under the point a chat client gives up on a tool call. AtimeoutMsabove 55000 is silently clamped to it, not rejected and not honored as asked. - What does it read when I am not signed in? Whatever a logged-out visitor sees —
silently. A bank, payroll or marketplace URL with no provider and no active session
commonly has a PUBLIC marketing page at that address, and
read.pagereturns it asok: truewith that page's real title and content, because nothing about the request failed — it answers the question "what is at this URL", not "what does MY account show here".needsLoginistrueonly when the page it landed on IS a sign-in form; a marketing page is not one, so this is not that case. Treat anok: trueread of a banking/payroll/marketplace URL as account data only when you hold a session for that site (ctx.session/a saved login) — without one, read the content back before trusting it is not just the public page.
A product page's price and stock — products.getAvailability
bowmark.products.getAvailability(url) reads one product page and returns its price and
stock without you parsing anything. It works when the page publishes schema.org
Product/Offer markup (many smaller stores and Shopify shops do), or when the url is on
a store Bowmark already has a provider for (today Target and Amazon.com). Many large
marketplaces publish neither — Amazon outside amazon.com and Mercado Livre among them —
and there it comes back ok: false, source: "none", with a warning that no markup was
found. Use read.page and parse the price out of content instead.
const p = await bowmark.products.getAvailability("https://example-store.com/products/widget");
// { ok, title, price: { amount, currency } | null, availability: "InStock" | "OutOfStock" | … | null,
// source: "ld+json" | "microdata" | "provider" | "none", url, requestedUrl, status, error, warnings }
// `price.amount` is in minor units: 1999 means 19.99.To find a product by name rather than url, bowmark.products.search({ query }) searches
Walmart, Target and Best Buy (add site: "amazon" for Amazon). For any other store, ask
get_library with the store's hostname first.
Controlling how the page loads
Every call to read.page accepts options for how the page loads and what format the content comes back in:
await bowmark.read.page(url, { strategy: "auto", format: "markdown", maxChars: 200000 })strategy — how hard to try loading the page
| Value | What it does | Use when |
|---|---|---|
"auto" (DEFAULT) | Fast read first; full page load only if needed (wall, interstitial, or empty) | You don't know what the page needs — this is the smart default |
"fetch" | Fast read only, never waits on a full page load | You need speed and can accept incomplete results, or the page is static HTML |
"browser" | Always does a full page load, skipping the fast read | You know the page is rendered by JavaScript |
Search results, filtered listings and infinite-scroll feeds are usually drawn by JavaScript.
The page's first response is an empty frame, or a generic results page with no products in it,
and the products arrive afterwards. "fetch" never waits for them, so it can hand back that frame
without failing: check escalationReason, which says the page needed a fuller load. "auto"
notices most shells and does the full load itself. If a results page still comes back without the
rows you can see in your own browser, ask for strategy: "browser". It is slower (often 30 to 55
seconds on a heavy page) and the rendering time is billed, so use it for the pages that need it,
not for every read.
format — what shape the content comes back in
| Value | What you get | Use when |
|---|---|---|
"markdown" (DEFAULT) | Headings, links, lists and code as markdown — densest form for models | You're feeding content to an AI, or you want readable text |
"text" | Visible text only, block structure as blank lines. Link and button urls are dropped: a "Book now" button arrives as the words "Book now" with nowhere it points | You only need the words, want to save tokens. To see where a link or button goes, use "markdown" (keeps every link's url) or "cleanHtml" |
"cleanHtml" | HTML with scripts, styles, nav, footer removed | You need structure and tags but not the page chrome |
"html" | Exactly the bytes the page served — nothing removed | You're parsing specific markup or need the raw source |
What read.page returns
One object per page. Every field below is always present unless it says otherwise.
Check more than content before you trust it — ok and warnings are what tell a
real page from a wall, a login page or an empty shell.
| Field | Type | What it tells you |
|---|---|---|
ok | boolean | 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 |
title | string | null | The page's <title>, else its first <h1>. null when it has neither — never the url standing in for one |
content | string | The page itself, in the format you asked for (markdown by default). "" when nothing could be read |
format | string | The format content is in, echoed back |
chars | number | Length of content |
truncated | boolean | true when content was cut at maxChars — see below |
warnings | string[] | Why a read is thin: a shell, an uncleared bot wall (named), the page's own data request failing behind a shell that still rendered (its own "No items." or similar — only flagged when the rendered page is itself small; a same-site telemetry call failing beside a page full of real content is not this), a redirect to a different page, a budget that ran out. Empty only when nothing was dropped |
url / requestedUrl | string | Where the read ended up versus what you asked for. A site that redirects you to its homepage is flagged in warnings too |
status | number | The HTTP status; 0 means the request never completed, and error says why |
error | string | null | Why the read failed, set instead of throwing — so one dead url in a read.pages batch does not lose the rest |
needsLogin | boolean | true when what came back is a sign-in page. read.page cannot pause for a person; use browser_agent for a page behind a login |
escalationReason | string | null | Why content may be incomplete — the page needed a fuller load than this read gave it (always set under strategy: "fetch" when so). null when nothing is known to be missing |
wall | object | null | The bot wall the page is behind and whether it was cleared — { vendor, cleared }, or null |
json | any | Only when the url served JSON: the parsed value, so you do not have to strip it out of content |
headers | object | Only when you passed { headers: true }: the response headers, names lowercased |
A url that serves a PDF comes back as the document's extracted text, not an error.
A certificate, a datasheet, a filing — content holds the whole document, with a
warning naming the page count (this url is a PDF (4 pages); content is its extracted text, whatever format was asked for). A scanned PDF with no text layer comes back
ok: false with a warning saying so. An image or other non-PDF binary is refused
outright — read.page extracts text from web pages and PDFs, never from images or
other binary files.
A table that shows ten rows at a time still comes back whole when the page carries
the rest. Many paginated tables (DataTables and similar) draw one page in the browser
and keep every row in a <script> as {"headers": […], "rows": […]} data. read.page
appends those rows to the end of content under [Inline JSON table rows]: one block
per table, headed by its title and row count, then a tab-separated header line and one
line per row. This works under strategy: "fetch" too, so a polling script can find a
row with a plain content.includes(id). A table that loads its rows from a separate
request is not covered by this; you get a table showing 10 of 409 rows warning instead.
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:
const page = await bowmark.read.page(url, { format: "html", maxChars: 600000 });The cut keeps the top of the page and drops the end. A price or a table low on a long
page is exactly what goes missing, so when truncated is true and the value you came for
is not in content, raise maxChars above the page length the warning names rather than
concluding the page lacks it.
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.
Loading strategies — wait for JavaScript or fetch once
read.page decides per call how to load each page:
| Option | What happens |
|---|---|
strategy: "auto" (default) | Start with fetch; if the page is a shell that loads its content with JavaScript, the read automatically escalates to a browser load. Costs more when it escalates, and slower than asking for browser upfront when you know you need it. |
strategy: "fetch" | One HTTP GET only — fastest, cheapest, good for static pages. Content it does not return is listed in escalationReason, and any rendering the page does after the GET is not included. |
strategy: "browser" | Load the page in a browser and wait for its JavaScript to run, within the whole read's time budget (timeoutMs, default 45 s, max 55 s). Rendering time is billed (Pricing § Page rendering). Use this when a page loads its data with AJAX or requires an interactive element. More in How it works. |
// Fetch a static page — fastest
const news = await bowmark.read.page("https://news.example.com");
// Wait for a page's AJAX content to load
const dynamic = await bowmark.read.page("https://app.example.com/data", { strategy: "browser" });Accessing bot-walled pages
Some sites refuse access from datacenter IPs or proxy exits. egress lets you choose an exit IP:
| Option | Use when |
|---|---|
egress: "default" (default) | The default rotating exit. Works on most sites. |
egress: "static-residential" | The site refuses default's exits. A small pool of fixed residential addresses, billed per request ($0.0001) rather than by bandwidth like default — see Pricing § Proxy bandwidth and exits. Use it only when default is blocked: a few fixed addresses get rate-limited where a rotating pool does not. |
// A site that blocks datacenter IPs
const data = await bowmark.read.page(url, { egress: "static-residential" });Does Bowmark get through Akamai or Cloudflare bot walls at all?
Often, and not always. read.page tries to clear a wall on its own before it answers: it
solves the CAPTCHA kinds listed under Captcha solves, and
loads the page in a full browser when a plain read was refused (an Akamai "Access Denied" page is
retried in a second browser build as well). Many protected pages come back as the real page.
Some sites refuse every route Bowmark has, the browser agent included. Bowmark does not know in advance which sites those are, and nothing is wrong with your script when you hit one. Here is how to tell, and what each step costs:
wall: { cleared: false }on the default read means the site refused Bowmark's normal exits and the automatic retries above did not get past it. Tryegress: "static-residential"once. It costs $0.0001 a request.wall: { cleared: false }again onstatic-residentialmeans the site is refusing every kind of address Bowmark reads from. Trying again, or a more specific url, will not change it, because the block is on the address and not on the request.- The browser agent is a different browser and a different network, so it is the one route
left. It is billed, and on a site that refused both reads above it usually fails within
seconds with an error starting
blocked:. Try it only when the answer is worth that charge, and try it once: ablocked:result is final for that site.
When all three refuse, Bowmark cannot read that site today. Tell your user that plainly, and
look for the same information somewhere the site does not control: another retailer, the
manufacturer's own site, or a provider get_library lists for a related site.
Reading a listing that spans multiple pages
A listing — search results, a job board, a product catalogue — often has pagination.
read.page returns no links field — markdown is the default format, so a link
on the page is already in the content string as [text](url). Do not guess the
pagination parameter; instead, read the page as markdown and pull the next-page link
out of that text with a regex:
await run(`
const allRows = [];
const seen = new Set();
let url = "https://example.com/products";
while (url) {
const page = await bowmark.read.page(url);
if (!page.ok) break; // wall or error — stop
// Extract product rows from this page's content
const rows = page.content.match(/Product: (.*?)\\n/g) || [];
const fresh = rows.filter((r) => !seen.has(r));
if (fresh.length === 0) break; // same rows as before — the site served page 1 again
fresh.forEach((r) => seen.add(r));
allRows.push(...fresh);
// Find the next-page link among the markdown links in this page's content
const linkPattern = /\\[([^\\]]*)\\]\\(([^)]+)\\)/g;
let nextUrl = null;
for (const [, text, href] of page.content.matchAll(linkPattern)) {
if (text.toLowerCase().includes("next") || text === ">") {
nextUrl = new URL(href, page.url).toString();
break;
}
}
url = nextUrl; // continue if there is a next link
}
return allRows;
`);Check that page 2 is really page 2 before you build the loop. Some sites ignore a
page parameter they don't recognise and serve page 1 again — no error, no redirect, no
warning, and often the same title and url you asked for. On QuintoAndar,
?pagina=2 returned exactly the listing IDs of page 1. So compare the rows, not the
title: pull a stable ID out of each row (a listing ID, a product SKU, a link), read page
1 and page 2, and confirm the two sets differ. The seen check above does the same thing
inside the loop, so a parameter that silently does nothing ends the loop after one page
instead of collecting page 1 forever.
A page parameter belongs in the URL (?pagina=2); read.page's options object only
controls how the page loads, and has no page or offset option.
If page 1's content has no next-page link at all, the site most likely loads more
results with JavaScript as you scroll ("infinite scroll"). Guessing parameter names will
not find them. Look at what the page loads for itself instead — read.page(url, { format: "html" }) and search the source for an embedded JSON blob (__NEXT_DATA__, a
window.__INITIAL_STATE__) or the API URL its "load more" calls. Or try the browser
agent, with the caveat below that paging an
infinite-scroll list with it is unconfirmed.
Full walkthrough: How it works § No provider? Read any page directly.
Reading and filling forms — web_form_fields
Public forms only
web_form_fields works on forms a signed-out visitor can reach. It cannot sign in. When the
form sits behind a login, getFields reports needsLogin: true and a warning saying so. Use
the browser agent for a form behind a login.
bowmark.web_form_fields reads the fields a form asks for (getFields) and writes answers into them (fillForm). It handles a booking or quote widget that only appears after a button is clicked, including one mounted in its own iframe.
const form = await bowmark.web_form_fields.getFields("https://example.com/quote");
// form.forms → every form found: [{ action, method, frameUrl, fields: [{ name, label, type, required, options? }] }]
// form.fieldCount, form.openedWith, form.multiStep, form.stepLabel, form.needsLogin, form.warnings
const step = await bowmark.web_form_fields.fillForm("https://example.com/quote", {
"Postcode": "4217", // keyed by a field's name or a word or two of its label
}, { advance: true }); // or { submit: true } to commit the form, or {} to fill without action
// step.filled, step.notFound, step.advanced, step.submitted, step.resultContent, step.warningsTo fill a form without submitting it or advancing to the next step, omit both submit and advance flags. The form will be filled with no data sent to the server and no action taken:
const filled = await bowmark.web_form_fields.fillForm("https://example.com/quote", {
"Name": "John",
"Email": "john@example.com"
}, {}); // or just omit the third argument entirely
// filled.submitted is false, no server call was madeUse this when you want to inspect what a form accepts before deciding whether to submit it.
A page with more than one form
getFields returns every form it found, not one guess. Look through forms and pick the one whose fields match what you came for. It does not rank them.
The trap is a form it never saw. By default (strategy: "auto") it reads the page's HTML first and only loads the page fully when that read finds no form at all, or nothing but a site search box. A footer newsletter or callback signup is a real form, so the read stops there. A quote calculator drawn by script after the page loads is then missing from forms. Fast Cover's homepage is this case: the default read returns a first-name / phone / email signup, and the "Get a quote" wizard is not in the answer.
When every form found asks only for contact details, the run's notes say so. Either way, if the form you want is not in forms, ask again in one of two ways:
// load the page fully, then read
await bowmark.web_form_fields.getFields(url, { strategy: "browser" });
// or name the control that opens the form: its visible text or a CSS selector
await bowmark.web_form_fields.getFields(url, { open: "Get a quote" });Under the default strategy an open hint always loads the page fully, because a click needs it. openedWith tells you what was actually clicked, and is null when nothing was.
What the open warnings mean
Each one sends you somewhere different:
| Warning starts with | What happened | Try |
|---|---|---|
nothing on the page matched the "open" hint | No button, link, text or CSS match | Copy the exact text off the button, or use a CSS selector |
the "open" hint … matched something on the page, but it could not be clicked (covered, disabled or gone) | It found the control, but something sat on top of it (a cookie banner, a modal) or it was disabled. Nothing was clicked | A more specific selector for the control itself. If the page needs a banner dismissed or a choice made first, use browser_agent |
clicked what matched …, but no form appeared | The click landed and opened nothing readable | The form may be on another page. Read where the button links to, or use browser_agent |
the "open" hint … was never tried — the time budget ran out first | It ran out of time | Pass a larger timeoutMs (default 30000) |
A warning on the hint does not mean the answer is empty. Whatever forms were on the page are still in forms. Check them before assuming either way.
When the page could not be read at all
getFields/fillForm can fail to reach the page before any form is in play — a dead domain, a refused connection, or the page answering a 404 or a 500. That shows up as readFailure, not as an exception, so check it instead of parsing warnings:
const form = await bowmark.web_form_fields.getFields(url);
if (form.readFailure) {
// form.readFailure.httpStatus: the status the SITE answered (404, 500, …), or
// null when the request never got that far at all (DNS, a refused connection)
// form.readFailure.detail: which, and whether trying again can help
return { ok: false, reason: form.readFailure.detail };
}A 404 means the address is dead and retrying will not help; an unreachable domain or connection is the same. A 5xx or a 429 may answer differently on a later try. readFailure is null exactly when the page was read, even with zero fields found — a form behind a wall or a login still reads the page.
Filling a wizard
fillForm always loads the page fully. One call is one step. advance: true clicks "Next"/"Continue", submit: true clicks the control that commits the form, and you call it again with the next step's values. notFound names any key that matched nothing, so a wrong guess at a label is visible. After a click, resultContent carries what the site answered back: a price, a matched branch, a validation message. Pass the same open you used for getFields so both see the same form.
Which button to ask for: advance: true on every step that ends in a "Next"/"Continue", submit: true on the last one — the step whose button sends the form ("Get quote", "Search", "Submit"). If advance reports no next/continue control, that step is the final one: call again with submit: true.
resultContent is plain text, not a structure — a string capped at 4,000 characters, read off whatever page or widget the click led to, or null when no click was requested or none registered. On a page with several forms or widgets it can carry their text too (a tracking box's validation error, footer copy), and on such a page a key can even land in a different form than the one you meant. Search it for the value you expect rather than parsing it by position.
It fills real form fields only — <input>, <select>, <textarea> and the custom pickers built on them. A listing of clickable cards or divs that opens no form (many cruise, tour and hotel result pages) gives it nothing to fill; getFields comes back with no fields or a no form appeared warning. Use read.page to read such a page, or browser_agent to click through it.
Reach for browser_agent instead when the form is behind a login, or when a step needs something other than filling fields and clicking Next.
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.
Not for daily jobs or repeating checks
The browser agent is for one-off tasks only — it cannot run on a schedule, monitor a page for changes, or check something daily. It is one task, a few minutes, then it stops.
For a weekly check, a daily monitor, or any repeating job: use a capability or read.page
inside a script that you schedule with Zapier, Make, GitHub Actions, or cron — that is
the recurring part, and Bowmark is the one call inside it. See Running it on a
schedule.
It is a one-shot live browser for ONE task on a site nothing else in the library
covers — a login, a form fill, a wizard, or a plain read on a site read.page cannot
reach — not a general fallback for anything a read failed on. For a page read.page
can't reach, try read.page with a more specific URL or { format: "html" } first;
reach for browser_agent when that genuinely isn't enough, including for a plain,
non-transactional read. Only a task that names a specific merchant to buy from, pay,
order from or check out with triggers its merchant-identity check, which confirms it
landed on that merchant before acting and fails fast with
merchant_verification_failed: could not confirm this is the correct merchant site if
it cannot. A plain read on a non-commerce site does not trigger it.
Asking it to paginate through an infinite-scroll list is undocumented and unconfirmed — describe the paging behavior you want in the task text (e.g. "scroll and load more results until no new items appear"), but this page does not promise it works on every site. Test it on your target before relying on it for a long list.
What it costs, and when to use it
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 as browser_agent.vendor. Estimating
costs has the per-minute browser rates and a worked
example so you can size a run before you start it.
When browser_agent is worth the cost:
- Multi-step workflows (sign-in, navigate filters, fill a form) — saves you manually building each step
- Sites with complex JavaScript UI where
read.pagecannot extract the data - Read-only lookups behind a login or CAPTCHA that you control
When to use something cheaper first:
- Simple reads or searches —
read.pageis free and usually sufficient; use it unless the page has no visible data in its HTML - Forms without interaction —
web_form_fields.fillForm()is cheaper and does not hold a browser open - Scheduled automation —
read.pageruns on a cron;browser_agentneeds a person for login/CAPTCHA
Typical costs per run:
- Simple lookup (read a page, extract 1–2 values): $0.03–$0.10
- Multi-step task (navigate, fill a form, submit): $0.10–$0.30
- Complex workflow (5+ steps, heavy JavaScript): $0.30–$1.00+
Start with read.page and only move to browser_agent if the data is truly unreachable without driving the UI.
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.
// ✗ 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 })})`);Your account can hold at most 3 open sessions at once. A finished or failed session still holds a slot
until you call bowmark.browser_agent.stop() on it. Use bowmark.browser_agent.list() to see what you're
holding, and pass the session id to stop() when you're done — a 20-minute idle timer will eventually close
it, but do not rely on that.
Extracting structured data with outputSchema: When you need the browser agent to extract data as JSON
that matches a specific structure, pass a JSON Schema in outputSchema. The agent will ensure its result
conforms to the schema, validating each field and skipping or remapping data that doesn't fit. This is
particularly useful for tasks like extracting listings, product details, or comparable data from a page.
start() never returns the extraction itself — only id, status, watchUrl, backend,
model and warnings. The answer is always on a LATER status(id) call, and status().result
is always a plain string, with or without outputSchema — a schema changes what the string
contains (valid JSON instead of prose), never its type. A full worked example, tested live, is on
Browser agent § A confirmed worked example.
// run 1 — start it. Without outputSchema, status().result will be free text
const { result: started } = await run(`
return bowmark.browser_agent.start({
task: "Extract all apartment listings from the search results, including price, bedrooms, and location"
});
`);
const { id } = started;
// run 2 — later, once idle
const { result: status } = await run(
`return bowmark.browser_agent.status(${JSON.stringify(id)})`,
);
status.result;
// "Found 5 apartments. 1 bed, $1200, Downtown. 2 bed, $1800, Uptown. ..."
// With outputSchema — status().result is a JSON STRING, not a pre-parsed object
const { result: startedJson } = await run(`
return bowmark.browser_agent.start({
task: "Extract all apartment listings from the search results as JSON",
outputSchema: {
type: "object",
properties: {
listings: {
type: "array",
items: {
type: "object",
properties: {
price: { type: "number", description: "Monthly rent in USD" },
bedrooms: { type: "number" },
location: { type: "string" },
url: { type: "string" }
},
required: ["price", "bedrooms", "location", "url"]
}
}
},
required: ["listings"]
}
});
`);
// run 2 — later, once idle
const { result: statusJson } = await run(
`return bowmark.browser_agent.status(${JSON.stringify(startedJson.id)})`,
);
const { listings } = JSON.parse(statusJson.result); // JSON.parse — result is a string
// listings is now: [{ price: 1200, bedrooms: 1, location: "Downtown", url: "..." }, ...]The schema is validated after the agent finishes its task. If the result doesn't match, the agent reports
which fields were problematic — this is your signal to refine the task description or loosen the schema
constraints. Do not pass outputSchema when you want free-text output — it adds latency and may force
the agent to structure things that are better left prose.
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 45
seconds (the waitMs parameter, capped there to leave headroom under the point — commonly
around a minute — where most chat clients kill a tool call), so two status() calls back-to-back
consume 90 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.
Set your own give-up deadline at 5 minutes or more, not at 2. Identical tasks do not
take identical time — the agent re-explores the page each run — so 90 seconds once and
past 120 the next is normal variance, not a regression. The 5-minute ceiling below is the
bound Bowmark enforces for you; a client deadline shorter than it (110s and 120s are the
usual picks, borrowed from the per-run() cap) abandons runs that were about to finish.
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. That failed and its error hold on every later
status() call, so you do not have to catch the exact poll that cut it off; a final step
reading "Run is cancelled. Exiting." is that same cut-off, not a separate failure. The
session stays open: send() a narrower instruction to try again in the same browser, or
stop() it. A long form with many repeated inputs (seven travellers' ages, say) is the
usual way to hit it — split it, or send() the next part once the first is filled. This is a fixed Bowmark-side
ceiling, not something you configure — there is no way to raise or lower it per call.
A site that blocks the browser outright also reports failed, and quickly — see the
table below. It never reads needs_input for this: nobody can take over and clear a
bot wall the way they can a login.
Reading a failed session's error
status(id).error carries one of several prefixes, and they call for different
responses — conflating them is the most common way a failed session gets mishandled.
error starts with | Whose side | What it means | What to do |
|---|---|---|---|
blocked: | The site | An anti-bot wall (Akamai, Cloudflare, PerimeterX, Imperva, DataDome, …) stopped the agent outright, usually quickly | Treat it as final for that site. Retrying with send() or a fresh start() will not get past a wall that already stopped the agent. If you came here because read.page was already refused, the site is refusing every route: see Does Bowmark get through Akamai or Cloudflare bot walls at all?. If you did not, a provider (get_library with the hostname) or read.page is worth one try. |
connection_failed: | Bowmark's own side | The browser's network path (our proxy/tunnel) never reached the site at all — this is not the site answering | Start a fresh session. A different proxyCountry may help, since it routes through a different exit, but it is not guaranteed to — the underlying cause is usually transient on our side, not something a specific country fixes. If it keeps failing across several countries and several fresh starts, it is worth a retry later rather than more country guessing. |
merchant_verification_failed: | Bowmark's own side | A safety check (only run when the task names a specific merchant to buy, pay, order or check out from) could not confirm the page the agent reached matches the merchant named — not evidence the site refused anything | Make the task name the merchant's own domain explicitly, then retry |
| anything else | Usually the automatic cutoff | A turn ran too long with no result and was cancelled — see the paragraph above this table on Bowmark's own cutoff | Narrow the task (fewer steps, a more specific instruction); raising timeoutMs does not help, since it bounds a different thing (below) |
proxyCountry takes any two-letter ISO country code (e.g. "us", "se"), passed
straight to the vendor's own proxy selection on the next start() — this page does not
promise coverage for any particular country, because which ones the vendor's pool
actually covers is outside Bowmark. There is no enumerated list to check against; try
one, and if connection_failed keeps happening across a couple of different countries,
the cause is more likely transient than country-specific — wait and start a fresh
session rather than cycling through more codes.
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) — what YOU are charged, not what the vendor is paid. How many
runs that buys depends on task length and complexity, since there is no flat per-run
price; see Estimating costs for the per-minute
rates and a worked example to size your own budget.
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:
// 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: 45000 })`);
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("…") 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. Open the
watchUrl in your browser — you will see the login page or challenge the agent is stuck on.
After you sign in or solve the challenge, click the "I'm done, hand it back" button on
the watch page to resume the agent, or send an instruction with browser_agent.send(id, "...")
— message is a plain string, not an object. Without one of those, status() stays
needs_input and polling runs out of time.
How soon that needs_input arrives varies from run to run — seconds on one run, a minute
or more on the next identical one — because the agent decides for itself when it is stuck. It
is told to hand off after about 15 seconds without progress, so most runs flip quickly, but a
running status on a login page is not by itself a hang: keep polling under the same 5-minute
give-up deadline as any other task. A turn that never hands off is cancelled at that ceiling
and reads failed; the session stays open, so send() it an instruction to stop and ask for
the login.
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 login() you can call
with bowmark.secret().
You cannot log in once with the browser agent and read the rest cheaply with `read.page`
They share no 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 keeps no
login state of its own. 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) 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.<site>.login(...) (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 — read.page cannot inherit that login, so it
cannot substitute for the agent on pages that need one.
`stop()` it when you are done
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 — idle, waiting on a question, or failed alike. bowmark.browser_agent.list()
shows what you are holding.
Each session starts fresh — nothing is remembered
Every browser_agent.start() opens a brand-new browser with no prior login state. If you
call start() again after the first session completes, you're starting a second browser
that has never signed into anything. A login does not survive across sessions. For a
weekly job that needs to sign in every run, the human has to sign in (at the watch link)
every run — use a typed provider with a stored login if you need unattended daily/weekly
jobs.
Account limits
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. Starts still in progress count too: fire 9
start() calls at once and 3 run while the other 6 throw the limit error. 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 per-minute request limit: the only limits are the 3 concurrent sessions and the monthly billing cap, and the session cap is what bounds how much you can check.
How many checks fit in an interval: a task usually takes a few minutes (2–4 is typical) and at most 3 run at once, so at most about 3 × (interval ÷ minutes per task) checks fit in any interval: about 15 in 15 minutes at 3 minutes a task. A watch over 40 items every 10–15 minutes does not fit, and no batching changes that. The browser agent suits occasional tasks, not polling many items every few minutes. For a recurring watch on a site with no provider, ask for the site: a provider answers in seconds and is not held to the 3-session cap.
Cost estimation: there is no flat per-run price — browser_agent is metered on the
model turns a task takes plus the browser time it holds open, itemized on your
billing dashboard as browser_agent.vendor. See
Pricing § Hosted browser agents for the per-minute
rates and a worked example to estimate 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.
Let Claude read and act on websites
Add Bowmark to Claude (claude.ai, Claude Desktop or Claude Code) and Claude can read and act on live websites through typed functions, signed in where you have an account. Free $10 to start.
Browser Agent
The paid last-resort fallback for a page that needs clicking, a date picked, or a login walked through — the real start/status/send/stop shapes and the time limits.