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.
Bowmark's surface is a language, not a menu of tools. You write code against the capability library and it executes against live sites.
There are two places you can write that code, and most of this page's readers want the first one.
| Where your code runs | How you get it | Typed | |
|---|---|---|---|
| Your own program | Your machine, your process | npm i @bowmark/web · pip install bowmark-web | Yes — full autocomplete |
An agent's run tool | Our sandbox, as a string | Nothing — it is the MCP run tool | No — a string gets no checking |
Same library, same servers, same results. The first is normal programming: real if,
real for, real closures, a debugger, your editor. The second exists because an agent
composing a task on the fly has no editor and no install step.
From your own code
Install it
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.tsA 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 bowmark-web bowmark-web-stubsbowmark-web — MIT, zero runtime
dependencies; urllib and json are the whole transport. Python 3 and nothing else.
The second package is the types.
PEP 561 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 browsers 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, 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 browser, cookie jar and cart — just await them one
after another. What session() buys is holding that state across separate calls driven
by your own control flow.
What it throws
Class / code | When | What to do |
|---|---|---|
BowmarkNeedsUserError | A call paused for a human login | Open err.handoff.url, then call again — the session is still open |
wire_refused | An argument JSON cannot carry | Fix it; the message names the exact path, args[0].when.checkIn |
bad_argument | An argument the signature does not accept | Same — the message names the path and what was expected |
unknown_function | No such function on a known unit | Check the name, or upgrade — the library may have grown it since this version |
no_api_key | No key was passed and BOWMARK_API_KEY is unset | Create a key at bowmark.ai/dashboard/keys and set it |
rung_undeclared | A capability's reach rung is dynamic per call, so session() cannot decide statically whether it needs a browser. read.page is the documented fallback when a site has no provider and uses this strategy. | Call it through run() instead: await run("const page = await bowmark.read.page(url); return page.content;") — the sandbox decides what resources are needed at runtime |
run_only | The unit is served only by run(), whatever rung it declares, because it needs a context the sandbox binds and a session cannot. browser_agent is the one that exists today. | Call it through run() instead — see When nothing else works: the browser agent. 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.
No install at all
The client is a convenience over two HTTP endpoints. Anything that can POST JSON can use Bowmark with nothing installed.
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);"}'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.
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 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.
bowmark.files is for a raw audit trail, not for the dedup logic above
You don't need it for the recipe above — Airtable's own Find/Create already tells new
from repeat. If you also want a copy of every week's full raw result saved to your
Bowmark account, add one line to the script: await bowmark.files.save({ name: 'check.json', text: JSON.stringify(flights) }). There's no way to read that file's
content back inside a later script — bowmark.files.get(id) hands back a presigned
URL, and a script has no fetch to follow it with (see What is
forbidden) — so it's a record you'd open by hand or fetch from
the automation tool itself, not a mechanism for diffing runs.
If the site you're checking needs a login your automation can't provide, store it once and the scheduled script keeps working unattended with no further pauses — see Storing a login for an unattended script 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.
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. |
return | Whatever you return comes back as result, JSON-serialized. |
One script is one live instance per unit, for the whole script. Two calls to the same
provider share its browser, cookie jar and cart; two separate runs do not. So the multi-step
flow that needs session() 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 { ok, status, result, logs, error, ms, trace }. The typed client throws
instead — this section is about the envelope.
Check status before ok. It is ok | error | partial | needs_user.
partial — it ran, the answer is narrower than you asked for
The script ran and result is real, but some of what it called never answered.
ok is still true. This is not a failure.
| Field | What it tells you |
|---|---|
incomplete.summary | What happened, in one sentence. |
incomplete.failures | Each call that threw, and what the site said. |
incomplete.degraded | Each call that answered while reporting its own results thin. |
Say so when you present the result. Name what was missed, and never call it complete, exhaustive, or "all" of anything.
Check incomplete.failures[].fixable before treating it as final. fixable: true
means your argument was rejected, not the site — the error names what that function
really takes, so fix it and run again. For anything else, re-running rarely helps.
needs_user — a site needs the user signed in
A pause, not a failure, and not something a script edit can fix.
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.
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: "acme_password", type: "password", hosts: ["acme.com"] })
// hands the user a one-time link to set it. No script is involved in this step.
// Inside the scheduled script, every run after that — no pause, no person required:
await bowmark.providers.acme.signIn({
username: "me@example.com",
password: bowmark.secret("acme_password"),
});bowmark.secrets.generate / bowmark.secrets.save are a different case worth telling
apart: they are for a credential the script itself creates during a run (a sign-up
password it picks, an API key a site hands back), not one a person already knows. Use
request_secret when the login already exists.
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.signIn(...) 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
signIn. On a site with no provider, a login inside a browser-agent task comes back as
needs_input with kind: "takeover" and waits for a person to sign in at watchUrl — by
definition attended. Check
GET /v1/library?query=… for the site before you plan around a
stored credential.
warnings is the only thinness signal
Some capabilities return { rows, warnings } (flights, hotels, cars); others return a
bare array — check the signature.
Where there is one, warnings names any site dropped from that search, and it is the
only signal that an answer is thin rather than complete. A result with warnings you
did not read is a result you cannot describe honestly.
Providers vs capabilities
// Capability — fans out across every site that can serve the task,
// routes around the ones that fail. The default.
const { flights } = await bowmark.flights.search({ from: "SFO", to: "JFK", depart });
// Provider — one specific site, when you want THAT site's answer.
const rows = await bowmark.providers.kayak.search({ from: "SFO", to: "JFK", depart });Prefer the capability unless you have a reason to pin a site. The same two tiers exist in both surfaces, spelled identically.
No provider for a site? Use read.page
get_library came back empty for a site — no capability, no provider. That does not mean
Bowmark cannot reach it: bowmark.read.page(url) (and read.pages for several URLs at
once) fetches any page as a plain GET and escalates to a real browser only if the page
forces one. It is the documented fallback for exactly this case, not a workaround.
Call it through run() — never session(), and never the bare top-level bowmark
client, which opens a session internally even for one call. Its reach rung is decided
per call rather than declared statically, so both refuse it with code: "rung_undeclared"
— see What it throws.
await run(`
const page = await bowmark.read.page("https://example.com/blog/post-1");
// \`content\` is markdown by default — pass { format: "html" } if you want the raw bytes.
return { title: page.title, preview: page.content.slice(0, 200) };
`);Check more than content before you trust it. The result also carries:
| Field | What it tells you |
|---|---|
ok | false when the read failed or came back as a wall, a login page or an empty shell rather than the page — under every strategy, including "browser" |
warnings | Why a read is thin: a shell, an uncleared bot wall (named), a redirect to a different page, a budget that ran out. Empty only when nothing was dropped |
url / requestedUrl | Where the read ended up versus what you asked for. A site that redirects you to its homepage is flagged in warnings too |
status | The HTTP status; 0 means the request never completed, and error says why |
servedBy / escalated / escalationReason | Which leg produced content, whether the plain GET was rejected for a browser's, and why a browser was needed |
wall | The bot wall the page is behind and whether it was cleared — { vendor, cleared }, or null |
truncated | true when content was cut at maxChars — see below |
content is capped at 200,000 characters by default, and maxChars raises it. A
page longer than the cap comes back cut, with truncated: true, a content cut at 200000 chars (page had 563960) line in warnings, and the run's status partial. That
is common with format: "html", where a page's raw markup is often several times its
text. It does not mean the format failed — ask for more:
const page = await bowmark.read.page(url, { format: "html", maxChars: 600000 });maxChars bounds what is returned, not the memory spent loading the page, and a run has
1024MB for its whole script. One large page is fine; on a read.pages batch every page's
content is held until the batch returns, so keep maxChars × number of pages under about
500MB — or read the big pages in separate run() calls.
Full walkthrough: How it works § No provider? Read any page directly.
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.
What it costs
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.
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 })})`);Two: poll it from LATER runs, never inside one. A single run() call is killed at 90 seconds
of wall clock. A script that starts the browser agent and immediately loops on status() in the
same run will hit this ceiling on the second poll. Each call to status() blocks for up to 60
seconds (the waitMs parameter), so two status() calls back-to-back consume 120 seconds — past
the ceiling before the browser agent has had time to work. Start in one run(), then poll from
separate later run() calls — each gets its own 90-second budget.
Three: a normal task takes a handful of polls, and a repeated step name is not a stall.
Most tasks reach idle in 1-4 poll cycles — roughly 1-3 minutes total, not one. Each
poll's steps shows the agent's current activity, and the same step name showing up on
back-to-back polls ("Read the page", "Considering interaction setup") is normal —
multi-step sites routinely need several passes at the same kind of action before they
resolve. It is not evidence of a hang. Keep polling until status is idle or
needs_input; stopping on a status plateau is the single most common way to abandon a run
seconds before it would have returned the answer.
However: Bowmark itself cuts off a turn that runs too long, so you rarely have to. A
single turn stuck past a few minutes with no result — drifting instead of converging, the
way a wedged agent does — is cancelled automatically and status() reports failed with
error naming what it was last doing. The session stays open: send() a narrower
instruction to try again in the same browser, or stop() it. This is a fixed Bowmark-side
ceiling, not something you configure — there is no way to raise or lower it per call.
timeoutMs on start, status and send is a different, narrower thing: how long we
wait for OUR OWN request to Browser Use's API to answer (create the run, poll it, send a
follow-up), default 30 seconds, capped at 55. It does not bound how long the agent
works on your task — raising it does not buy the agent more time, and it cannot be used to
make a run stop sooner than Bowmark's own ceiling above. If a task consistently reads
failed from the automatic cutoff, that is a sign the task itself needs to be narrower
(fewer steps, a more specific instruction), not a limit to raise. The default budget is
2 USD (maxCostUsd), which at typical browser-agent rates ($0.02–$0.25 per run)
translates to roughly 5-100 runs before the browser stops.
Field-by-field verification: The result field contains a summary of what was
completed. For write operations (form submissions, uploads), the summary describes what
was filled in and includes any confirmation text from the site. If you need to verify
individual fields were filled correctly, include that detail in your original task
description — for example, "After filling the healthcare form, describe each field value
you entered so I can verify correctness."
Start in one run, keep the id, and poll from as many later ones as the task needs:
// run 1 — start it, and show your user the watch link
const started = await run(`return bowmark.browser_agent.start(${JSON.stringify({ task })})`);
const { id, watchUrl } = started.result as { id: string; watchUrl: string };
// run 2, 3, 4 … — one round trip each, and no run holds the browser open waiting
for (;;) {
const poll = await run(`return bowmark.browser_agent.status(${JSON.stringify(id)}, { waitMs: 60000 })`);
const st = poll.result as { status: string; question?: string; result?: unknown };
if (st.status === "needs_input") { /* ask your user, then browser_agent.send(id, answer) */ break; }
if (st.status === "idle") { await run(`return bowmark.browser_agent.stop(${JSON.stringify(id)})`); break; }
}Four: it has no stored credential, so it cannot do an unattended login. start() takes
task and a few knobs (backend, model, maxCostUsd, proxyCountry, timeoutMs) — there
is no credential or secret field, and
bowmark.secret("…") cannot be reached from a
task string: interpolating one yields the literal placeholder ‹secret:name›. When a task
hits a login, a CAPTCHA, or any other challenge only a person can solve, the agent returns
needs_input with kind: "takeover" and waits for a person to act at watchUrl. A stored
login is a typed-provider feature, so a scheduled script that has to sign in needs a site
the library already covers — the browser agent is the attended fallback, not an unattended
one.
This holds on every run, not only the first. Nothing about a login is remembered between
sessions — a script that walked a person through signing in yesterday still needs a person at
watchUrl today, on the identical site, because there is no stored login to replay. If your
plan for a repeat script is "reuse the login from last time", that plan does not work on
browser_agent; it only works on a site with a typed provider and a signIn you can call
with bowmark.secret().
You cannot log in once with the browser agent and read the rest cheaply with `read.page`
They are two different browsers with no shared cookie jar. The browser agent drives a
vendor-hosted browser (Browser Use) that ctx.browserAgents owns and bills to the run
that started it; read.page opens a browser from Bowmark's own pool. Nothing carries a
cookie between them — not across separate run() calls, not inside one run(), and not
through session(), because read.page refuses session() outright
(code: "rung_undeclared", see What it throws) 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>.signIn(...) (using a stored
bowmark.secret() for an unattended script), then make repeated typed calls inside that
same session(). That reuses one cookie jar across every call, at typed-provider rates.
If the site has no typed provider, there is no cheap path. Every page that needs the
login goes through browser_agent again, at its per-run
rate — 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. bowmark.browser_agent.list() shows what you are holding.
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. Use bowmark.browser_agent.list() to see what
you're holding.
Monthly spend cap: Each account has a monthly spend cap for browser-agent usage (defaults to $2 per run, configurable up to $25). When your account approaches its monthly limit, new browser-agent tasks are refused with an error message directing you to adjust your billing settings at bowmark.ai/dashboard/billing.
No rate limit on calls: You can start as many browser-agent tasks as you want (one at a time, within the 3-session concurrent limit). There is no per-minute request limit — only the concurrent session limit and the monthly billing cap.
Cost estimation: Each browser_agent call costs $0.02–$0.25 per run, depending on task complexity and duration. Typical tasks run 1-3 minutes and cost $0.02–$0.10. See Pricing § Hosted browser agents for the full cost breakdown and how to estimate your costs at scale — particularly useful if you're running hundreds or thousands of tasks.
The 90-second ceiling is not specific to the browser agent: it is the wall-clock limit on
any single run(), and the reason a long job belongs in several runs rather than one.
Reading this with an agent?