How it works
Two calls. Read the library, then run a script against it on the live sites.
Reading this with an agent?
curl -s https://bowmark.ai/docs/how-it-works.md. The whole site as one file: https://bowmark.ai/llms-full.txt. Index of every page: https://bowmark.ai/llms.txt.Every Bowmark task is the same two calls, in this order.
1 — Read the library
get_library({ query }) returns the callable functions that match what you want to
do, with their argument shapes and return types.
It touches no website. It is one read-only call, and an unrecognized query returns a one-line index of the whole library rather than an error — so the check never dead-ends and never costs you an attempt.
const library = await get_library({ query: "flights" });get_library is an MCP tool and HTTP endpoint only. It is not callable from your own Node or Python program — see the HTTP surface below, or use the CAPABILITIES.md and PROVIDERS.md lists to discover what you can call. npm i @bowmark/web gives you bowmark.<capability>.<fn>() and bowmark.providers.<site>.<fn>() — never a get_library export.
Pass what you want to do (flights, price a GPU) or a company if one was named
(Kayak). You get what you asked about and nothing else.
Every response is bounded and says so when it is a slice. When it does, absence from the list proves nothing — the fix is a narrower query, not a conclusion.
2 — Run a script
You write plain async JavaScript against what came back, and run executes it in a
sandbox. The sandbox reaches the live sites; your script cannot.
const { flights, warnings } = await bowmark.flights.search({
from: "SFO",
to: "JFK",
depart: "2026-09-01",
});
return { flights: flights.slice(0, 3), warnings };Bowmark works out how to reach each site for you — fast where the site allows it, thorough where it demands it — and fans a capability out across every site that can serve it.
There is no third call. What comes back is an envelope — ok, status, result, logs,
error and ms are always there, plus a handful of other fields (runId, incomplete,
needs, …) that show up only when relevant. Full field-by-field reference: Scripting §
Reading the response.
The same two calls over plain HTTP
Nothing above needs an MCP client. The two calls are two HTTP endpoints, so an agent with only a shell or a fetch tool and an API key can use Bowmark with nothing installed.
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 { flights } = await bowmark.flights.search({from:\"SFO\",to:\"JFK\",depart:\"2026-09-01\"}); return flights.slice(0,3);"}'Every request needs the key; one without it is refused with the steps to get one. See API keys.
Why a script instead of a tool call
One script, several calls, combined however the task needs. That is the thing you cannot do by driving a browser step by step, and it is the reason the surface is a language rather than a menu of tools.
const dates = ["2026-09-01", "2026-09-02", "2026-09-03"];
const runs = await Promise.all(
dates.map((depart) => bowmark.flights.search({ from: "SFO", to: "JFK", depart })),
);
return runs.flatMap((r) => r.flights).sort((a, b) => a.price - b.price)[0];Three days of fares, ranked, in one round trip.
The language itself is a separate topic — the globals you get, what is forbidden inside the sandbox, and how to read the response envelope are all in Scripting.
And you do not have to be an agent to do any of this. npm i @bowmark/web or
pip install git+https://github.com/bowmark-ai/web.git#subdirectory=python (Python 3.11 or newer; the PyPI names are empty placeholders) gives you the same library as typed functions in your own
process, with autocomplete and no script string — see
Scripting.
Two tiers in the library
Capabilities — bowmark.flights.search(...). The default. One call fans out
across every site that can serve the task and routes around the ones that fail.
Providers — bowmark.providers.kayak.search(...). One specific site. Reach for
one when you want that site's answer rather than the best available.
Prefer the capability unless you have a reason. It is the one that survives a site going down.
No provider? Read any page directly
A site with no dedicated Bowmark provider can still be read through the generic
bowmark.read capability. It reads any URL fast, and takes the slower, fuller load
only when the page needs it.
Call it through run() — never session(), and never the bare top-level bowmark
client (it opens a session internally even for one call). How read loads each page
is decided per call, so both of those refuse it with code: "rung_undeclared".
await run(`
const page = await bowmark.read.page("https://example.com/blog/post-1");
// \`content\` is markdown by default — pass { format: "html" } if you want the raw bytes.
return { title: page.title, preview: page.content.slice(0, 200) };
`);Both read.page (one URL) and read.pages (many URLs at once) work the same way: a
fast read first, and a full page load only when the page needs one. Pair them with the
run call to read arbitrary sites without building a provider for each one.
If the URL is a JSON API, read.page does not just pass the bytes through. A JSON
response comes back parsed, in json, alongside the markdown content. The site's own
HTTP error comes back as-is too: ok: false with the site's real status and body, and
a rate limit (429) is returned exactly as the site sent it — Bowmark does not retry it
for you. See Scripting § No provider for a site? Use
read.page for the full field list.
Don't know which page on a site has what you want? Use bowmark.read.urls() to list
a site's pages from its sitemap and internal links, or bowmark.search.web() to search the
web. Neither carries a title/description pair sorted by relevance — read each one's real
shape:
const { urls } = await bowmark.read.urls("https://example.com", { maxUrls: 50, pathPrefix: "/announcements" });
// each is { url, source, depth, lastmod } — no title, no description
const { results } = await bowmark.search.web("UK van insurance comparison sites");
// each is { source, rank, title, url, snippet, published }A slow page has a fast-fail option, and it is worth knowing before you need it.
timeoutMs is the budget for the whole read, end to end — default 45s and capped at
55s, deliberately under the point at which a chat client gives up on a tool call. If you
do not want to wait on a page at all, pass strategy: "fetch" (fast mode): it never
waits on a slow page, comes back in about 200ms whatever the page is, and still sets
escalationReason when the content may be incomplete, so you learn the page needed more
instead of spending the budget discovering it.
const fast = await bowmark.read.page(url, { strategy: "fetch", timeoutMs: 15000 });
if (fast.escalationReason) {
// The fast read may be incomplete. Only now wait for the full load, knowingly.
const full = await bowmark.read.page(url, { strategy: "browser", timeoutMs: 45000 });
}These are all the options read.page takes: format, strategy ("auto", "fetch"
or "browser"), timeoutMs, maxChars and headers. There is no "wait for
this element" option and no retry count. If a page's results load by script after a bot
check and still come back empty at the full timeoutMs, waiting longer will not help. Use
the site's own provider if get_library has one, or
bowmark.browser_agent for a page you have to operate. A full page load
(strategy: "browser", or an auto read that needed one) is billed, roughly
$0.002-$0.006 per read — see Pricing § Page rendering.
content is capped at 200,000 characters unless you pass maxChars. Over the cap
the read comes back with truncated: true, a content cut at … chars (page had …)
warning, and the run's status partial — most often with format: "html", whose raw
markup runs long. { maxChars: 600000 } returns the whole page. Keep maxChars × pages
under about 500MB on a read.pages batch: a run has 1024MB for its whole script.
Reading several slow pages that each need the full load in ONE script is the shape
that runs long — split those across separate run calls. A batch that does run long returns the
pages that finished, with a row naming the budget for the ones that did not.
read.page still only reads. If the data you want only shows up after a click, a
date picked on a calendar widget, or a form filled in, that's not a wider fetch — it's
bowmark.browser_agent, a hosted browser agent that drives the page for you.
When read.page comes back with wall set and uncleared (or an escalationReason naming a bot wall),
Bowmark has already tried its available routes. Stop retrying the same read or a more specific URL:
the site is refusing the routes Bowmark can use. The billed bowmark.browser_agent is the one
different route left, and on some sites it is refused as well.
Blocked pages and read options
says how to tell, and when the billed try is worth it.
browser_agent does one task at a time; Bowmark does not schedule it for you. On a public
page with no login or CAPTCHA, your own cron or scheduled script can start and poll it
unattended. Each run normally takes 1–3 minutes and is billed, so it is not a fit for a live,
seconds-long lookup or a high-volume job. A login that needs a person means that person must be
present for every run; use a typed provider with a stored login for unattended signed-in work.
A login inside browser_agent is attended only — the person has to be watching. The
agent completes the sign-in at watchUrl, then your script continues. For scripts that run
unattended on a schedule, you need a typed provider with a login() function and the
request_secret / bowmark.secret() pattern described in Scripting § Storing a login for
an unattended script.
See Browser Agent for the full reference — the real start/status/send/stop shapes, the time limits, and the login handoff — or Scripting § When nothing else works: the browser agent.
Data licensing and terms
Where does the data come from? Bowmark runs requests through its own infrastructure and egress, hitting the live sites you name in your scripts. You are responsible for ensuring that each request is authorized by the target site's terms of service — a public read, a data crawl under license agreement, or a user's own account. Requests originate from Bowmark's servers, and any rate limits or blocks are enforced against those addresses, not your own.
Can you use or redistribute the data Bowmark reads? The data your runs return is licensed to you for internal business purposes — reports, analysis, decision-making, and your own product features for your customers. You do not get an intellectual property guarantee from the data itself (it belongs to the site you read it from), and you cannot resell or republish it outside your business. For more detail, see our Terms of Service § Output License and the per-request limits that apply to certain data.
No coverage at all yet? Say so — that's how new coverage gets built
Sometimes get_library has nothing for the task, and bowmark.read isn't enough
because what you actually need is a structured capability — search, compare, book —
for a site or a category Bowmark hasn't covered yet. That isn't a dead end.
Bowmark's library grows from what callers actually ask for. Every run you make is
one data point, and — if your agent has an MCP client — calling report on it is
the direct way to flag a miss instead of a silent one:
await report({ runId, report: "no provider for UK van-insurance comparison sites" });runId is optional — pass the one run returned if this follows a call, or omit it
to report a gap get_library itself couldn't fill. There's no ticket number and no
promise of a date; what you get is that the miss is now counted, and coverage is
prioritized by how many callers hit the same gap. A site with no coverage today can
have it next week if enough real usage asks for it.
report is an MCP tool only — the third one, alongside get_library and run —
never something a script calls. The snippet above is what your agent calls directly,
the same way it calls get_library; it is not a bowmark global, not callable from
inside a run script, and there's no POST /v1/report or SDK method for it. Calling
bowmark's own scripting surface over plain HTTP or @bowmark/web? There's no direct
way to flag a gap yet — but you don't need one: an empty or unmatched get_library
result is already counted as demand automatically, so simply calling it is the signal.