Quickstart
Get one real result out of Bowmark before you install anything, then wire it into your agent.
Most integrations ask you to install first and trust that something happened. Bowmark is a plain HTTP endpoint behind an API key, so you can do it the other way round: get a real answer out of it first, decide it is worth installing second.
1 — See it work. Nothing installed.
Every call needs a Bowmark account. Sign up at
bowmark.ai/sign-up, create a key at
bowmark.ai/dashboard/keys, and export it as
BOWMARK_API_KEY. Every account gets $10 of usage free each month — see pricing. Then paste this into any terminal:
curl -s https://api.bowmark.ai/v1/run \
-H "Authorization: Bearer $BOWMARK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"script":"const { offers } = await bowmark.pcparts.search(\"RTX 5080\"); return offers.slice(0, 3);"}'It sits there for about ten seconds, because in that time Bowmark is searching Newegg, Micro Center and B&H at once and price-sorting what comes back. This is what it printed, recorded 2026-08-24:
{
"ok": true,
"status": "ok",
"ms": 9953,
"result": [
{
"store": "newegg",
"price": 1449.99,
"title": "MSI INSPIRE GeForce RTX 5080 16GB GDDR7 PCI Express 5.0 Graphics Card"
},
{
"store": "bhphoto",
"price": 1499.99,
"title": "Gigabyte GeForce RTX 5080 MASTER Graphics Card"
},
{
"store": "newegg",
"price": 1549.99,
"title": "PNY GeForce RTX 5080 OC 16GB 256-Bit GDDR7 DLSS 4.0 Graphics Card"
}
]
}Your prices will differ, and that is the point: those numbers came off the retailers' live pages while the request was open, not out of an index.
Using Python? Set the User-Agent header
The curl example above works out of the box. If you translate it to Python using
stdlib urllib.request, you'll get a 403 Forbidden (error code 1010) because
Python's default User-Agent header is blocked by our WAF. Fix it with one line:
headers = {
"Authorization": f"Bearer {os.environ['BOWMARK_API_KEY']}",
"Content-Type": "application/json",
"User-Agent": "my-app/1.0", # ← add this line
}If you use requests, httpx, aiohttp, or the bowmark-web package, you're not
affected — their User-Agent strings are fine. Full details and a working example are in
Scripting.
What the ten seconds were spent on
Every response also carries a trace. It is the receipt — one row per site Bowmark
actually reached, with what each one gave back. Summarised, from the same run:
newegg ok 3 offers 4100ms
bhphoto ok 3 offers 5426ms
microcenter empty 0 offers 9943ms
-> pcparts ok 9946msThree retailers were searched in parallel and Micro Center had nothing matching, so the answer is the cheapest of two. Bowmark reports that rather than hiding it — which is the difference between a result you can quote and one you can only hope about.
That was the whole product
You just wrote a script against the capability library and had it executed against live retail sites. You opened no browser, rendered no page, and installed nothing. Everything below is about getting your agent to do that on its own.
2 — Install it into your agent
One line, one time. Pick your host. Where a plugin exists, take it — it wires the MCP and installs the skill that teaches your agent when to reach for Bowmark, and it lands in the host's own plugin list. Installation has the config for every host, including the double-click bundle for Claude Desktop.
claude mcp add bowmark --transport http https://api.bowmark.ai/mcp \
--header "Authorization: Bearer $BOWMARK_API_KEY"Run it from any terminal. Every project picks Bowmark up — there is no per-project step, and nothing to name in a prompt. Claude reaches for it on its own when a task needs the live web.
Want the skill bundled in too? One install does both:
claude plugin marketplace add bowmark-ai/plugin
claude plugin install bowmark@bowmark-aiFour clicks, about a minute. Full screenshots are on Installation.
Go to chatgpt.com/plugins and click + (top right).
Name it Bowmark, choose Server URL, paste https://api.bowmark.ai/mcp/chatgpt-app,
set OAuth, tick the box, hit Create.
Hit Connect on the next screen and sign in to Bowmark.
In a chat, type @ and pick Bowmark. You must do this every turn — see the warning below.
Settings → MCP → Add new MCP server, then paste:
{
"mcpServers": {
"bowmark": {
"url": "https://api.bowmark.ai/mcp",
"headers": { "Authorization": "Bearer <your Bowmark API key>" }
}
}
}codex plugin marketplace add bowmark-ai/pluginThen codex /plugins and install Bowmark from the list. Or edit
~/.codex/config.toml directly:
[mcp_servers.bowmark]
url = "https://api.bowmark.ai/mcp"
bearer_token_env_var = "BOWMARK_API_KEY"npm i @bowmark/web (Node/TypeScript) or pip install bowmark-web bowmark-web-stubs
(Python 3.11 or newer) — same typed bowmark.* surface as everywhere else in this doc, callable
straight from your own code with no MCP in between. The install command and the first
real snippet are on Installation → Your own code —
kept there, not copied here, so this page can't drift from what the published
packages actually export. read.page is the one function that needs run()
instead of the plain client; that page says why.
Same URL, same key in the Authorization header. Windsurf, Claude Desktop, LM Studio,
browser-use, your own agent — every path is on Installation.
If your host speaks stdio rather than HTTP, bridge it:
{
"mcpServers": {
"bowmark": {
"command": "npx",
"args": ["@bowmark/mcp"],
"env": { "BOWMARK_API_KEY": "<your Bowmark API key>" }
}
}
}3 — Give your agent its first task
Paste this into a fresh chat, verbatim:
Price an RTX 5080 across every retailer you can reach, and tell me
which store is cheapest and what it costs right now.In ChatGPT, type @ and pick Bowmark first. Everywhere else, just send it.
A working agent does three things, in this order, and you can watch each one:
- Calls
get_librarywith something like"pc parts". No site is touched. - Calls
runwith a few lines of JavaScript it wrote against what came back. - Answers with a store name, a price, and a link you can click.
If it answers without calling a tool, it did not use Bowmark — it answered from
memory or browsed. In ChatGPT that is almost always the missing @.
4 — When it doesn't work
Three failures account for nearly all of them.
The agent never called Bowmark
In ChatGPT, a custom connector is inert until you attach it for that turn. This is
documented OpenAI behaviour and no amount of prompt wording changes it. Type @,
pick Bowmark, then ask. Everywhere else, the tools are in scope on every turn.
TypeError: Cannot read properties of undefined
The script called a function with the wrong argument shape and then read a field off
undefined. Signatures are not uniform on purpose — bowmark.pcparts.search takes a
plain string, bowmark.flights.search takes an object.
// wrong — search takes a string here
const { offers } = await bowmark.pcparts.search({ query: "RTX 5080" });
// right
const { offers } = await bowmark.pcparts.search("RTX 5080");The fix is always the same: read the library for that capability and check the signature. It costs nothing and touches no site.
How you read it depends on which channel you are on. In an agent it is the
get_library tool. Over HTTP — including if you are POSTing to /v1/run as above — it is
a second endpoint, never something you can call from inside the script:
curl -s "https://api.bowmark.ai/v1/library?query=pc%20parts" \
-H "Authorization: Bearer $BOWMARK_API_KEY"It answered, but the answer looks thin
Read warnings and the trace together — they say different things, and reading
only one of them is how a thin answer gets quoted as a complete one.
warningsnames every site dropped from the fan-out: it failed, or it timed out. A populatedwarningsmeans a site was never heard from at all.tracehas one row per site regardless, with what each one returned. A row readingemptyis a site that answered and had nothing matching.
So an empty warnings does not mean every site had a result — it means none were
dropped. The run above is exactly that case: warnings was [], and Micro Center
still contributed nothing. Read both before you call an answer complete.
Check status before ok — partial means the script ran and the result is real
but narrower than you asked for. Full rules in Scripting.
The site I need isn't supported
If the library comes back empty for the site or capability you're looking for, there's no dedicated provider for it yet. That doesn't mean Bowmark can't reach it.
Call bowmark.read.page(url) (or read.pages for several URLs at once) through run()
— it reads any page fast, and takes a fuller load only when the page needs one. This is Bowmark's own answer to "the site isn't in the list", and it
works for most pages with no dedicated provider at all. session() and the top-level
bowmark client both refuse it (code: "rung_undeclared" — how it loads each page is
decided per call, not up front), so run() is not optional here. See How it works § No provider?
Read any page directly for the
full example.
If a page genuinely needs more than that — it's behind a login, needs a click or a date picked on a widget before the data shows up, or you want it turned into a typed capability rather than raw HTML:
- For login-gated sites, Bowmark already handles them through a login handoff flow.
When a call hits a login, it returns
BowmarkNeedsUserErrorwith a URL — open that URL to sign in, then call again. The same session stays open, so your script continues from where it paused. No email required. See Scripting § Handling Login Handoff. - Reach for
bowmark.browser_agentif the blocker is interaction, not just access — a form to fill, a calendar widget to click through, a wizard to drive. It's Bowmark's own hosted browser agent, built for exactly the caseread.pagecan't handle. It always pauses for a human at a watch link when a login is needed. See Scripting § When nothing else works: the browser agent. - Email support@bowmark.ai only if you want Bowmark to build a typed provider for a specific site or capability — that speeds up future uses and stores logins unattended. Bowmark prioritizes sites by actual usage and by direct request. Your request is tracked as demand — every time an agent reaches for a site we don't cover, we see it and add it to the priority queue.
- Keep going anyway. Your agent can still reach the site via a browser or a direct API call if one exists. Bowmark works alongside whatever your agent does, not instead of it.
Write actions and multi-step flows with browser_agent
Everything above reads data. Bowmark also runs a hosted browser agent for sites that need interaction — filling forms, clicking through wizards, signing in, any action a person would perform in their browser.
When to use browser_agent
- The site needs interaction you can't script: a form to fill, a date to pick on a calendar widget, a wizard to step through
- You're writing data: form submissions, order placement, profile updates, content uploads
- The site needs multiple steps in sequence: login, navigate, fill form, verify, submit
- Standard reads won't work because the data only appears after you interact with the page
If the site just needs a login and then serves you regular pages, you don't need browser_agent — use the SDK or a capability directly with a caller-supplied login. browser_agent is for the interaction itself.
The async polling pattern
browser_agent runs asynchronously. You start it in one run(), then poll for status from later runs. You cannot start it and immediately wait for the result in the same run — that would block forever and hit the 90-second timeout.
// Step 1: Start the browser agent (in one run)
const { result: started } = await run(
`return bowmark.browser_agent.start({
task: "Fill out the form on athenahealth.com with patient ID 12345, add clinical note, and submit"
})`
);
const { id, watchUrl } = started;
console.log("Browser agent started. Watch at:", watchUrl);// Step 2: Poll from a LATER run (or multiple later runs)
// You can do this immediately, or wait and try again later
for (let i = 0; i < 10; i++) {
const { result: status } = await run(
`return bowmark.browser_agent.status(${JSON.stringify(id)}, { waitMs: 45000 })`
);
console.log("Status:", status.status);
if (status.status === "needs_input") {
// The browser agent needs you to answer something (login, captcha, etc)
// Use browser_agent.send() to provide input
break;
}
if (status.status === "idle") {
// Done! The result is in status.result
console.log("Completed with result:", status.result);
await run(`return bowmark.browser_agent.stop(${JSON.stringify(id)})`);
break;
}
// status.status === "running" means keep polling
}Understanding timeouts and polling
What waitMs means: status() blocks for up to that many milliseconds waiting for the agent to finish. If it doesn't finish in that time, it returns status: "running" and you can poll again.
waitMs: 45000(45 seconds) — recommended for most tasks. The polling call itself will wait this long before returning, leaving headroom under the ~60s chat-client ceiling.waitMs: 5000(5 seconds) — faster feedback loop if you want to check on progress frequently- Default if you omit it — the endpoint waits as long as it can
The whole run() call times out at 90 seconds. This is the ceiling for run(), not specific to browser_agent. A script that starts the agent and then immediately calls status() in a loop will hit this ceiling on the second poll.
Typical task takes 1-4 polls (about 1-3 minutes total). A repeated step name showing up on back-to-back polls is normal — multi-step sites need several attempts before they resolve. Don't stop on a status plateau; keep polling until you see idle or needs_input.
Give it at least 5 minutes before your own code gives up. The same task can take 90 seconds once and a little over two minutes the next time — the agent explores the page afresh each run, so its path (and its step count) varies. Bowmark cancels any turn that runs past 5 minutes with no result and reports it as failed, so a client-side deadline at or above that never cuts off a run that would have finished, and never leaves you waiting forever either. A deadline of 110 or 120 seconds — set because a single run() is capped there — is the most common way to turn a working lookup into a "timeout": that cap is per run() call, not on the agent.
// ❌ DON'T do this — the second run() call will fail
const start = await run(`return bowmark.browser_agent.start({task})`);
const status = await run(`return bowmark.browser_agent.status(start.result.id)`);
// This throws because status() inside a loop hits the 90s ceiling
// ✓ DO this — separate runs, so each gets its own 90s budget
const start = await run(`return bowmark.browser_agent.start({task})`);
// ... wait, then in a DIFFERENT call ...
const status = await run(`return bowmark.browser_agent.status(start.result.id)`);Success verification
The result field tells you what the browser agent found or did:
- For reads (checking information):
resultcontains the extracted data — text, values, confirmations - For writes (form submissions, uploads):
resultcontains a summary of what was written and any confirmation text from the site - Field-by-field verification: The summary describes what was completed. If you need to verify individual fields were filled correctly, include that in your original task description ("After filling the form, take a screenshot and list each field you filled")
Lists of results can pick up rows that are not results
Many search pages show a second list after their own — "similar listings nearby", "you may also like", sponsored rows. Bowmark tells the agent to leave those out and to say how many it excluded, but nothing structurally guarantees every returned row belongs to the search you asked for. Ask for each row's own location or category as a field (an address, a neighbourhood, a brand) and filter on it before you aggregate — a mean is the first number a few stray rows will move.
status.result is a plain object; the structure depends on what you asked the agent to do:
const status = await run(`return bowmark.browser_agent.status(id)`);
// status.result might be:
// { message: "Form submitted successfully", confirmation_number: "ATH-123456" }
// or
// { order_placed: true, order_id: "12345", estimated_delivery: "2026-09-28" }Cost estimation
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 your costs at scale.
Unattended logins are not supported here
bowmark.secret() stores a credential for a typed provider's own signIn() call — it
is not a way to log in through browser_agent. start() takes a plain-language task
string and nothing else, and interpolating bowmark.secret("…") into that string sends
the browser agent the literal placeholder text, never the password, with no error. When
a task hits a login, browser_agent returns needs_input with kind: "takeover" and
waits for a person to act at watchUrl — every run, not just the first.
If the site has a typed provider, sign in once with providers.<site>.signIn() (using a
stored bowmark.secret()) and make repeated calls inside that session() instead. See
Scripting § When nothing else works: the browser agent
for the full explanation.
Stop when done
An open browser_agent session costs money until you stop it. Always call stop() when the task is done, even if the status was idle.
await run(`return bowmark.browser_agent.stop(${JSON.stringify(id)})`);Where to go next
- Providers — every supported site, by name, and what you can
do with each. To check one site, call
GET https://api.bowmark.ai/v1/library?query=<hostname>with your key. It answers yes or no without you having to read the whole list. - How it works — the two calls, drawn, and why the surface is a language rather than a menu of tools.
- Scripting — the globals, the sandbox rules, and how to read the response envelope. Includes a full reference for browser_agent.
- Installation — every host, with screenshots, plus API keys and what they unlock.
Reading this with an agent?