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.
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())No API key, no signup. Every browserless capability works anonymously on a per-IP daily budget.
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.
An anonymous session cannot hold a browser
Capabilities that need a real browser are refused in session() and in the one-shot
bowmark.* unless you send an API key:
BowmarkError: code "browser_needs_api_key"
path bowmark.pcparts.searchTwo ways past it. Add a key — see API keys — or send
that one call through run(), which reaches every rung
anonymously. Browserless capabilities are unaffected either way.
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 |
browser_needs_api_key | A browser rung, anonymously | Add a key, or use run() |
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_…. Optional. Raises the cap, unlocks browser rungs and signed-in sites. |
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 reach a browser rung anonymously, or 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.
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" },
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, urllib.request
def bowmark_run(script: str, api_key: str | None = None) -> dict:
headers = {"content-type": "application/json", "user-agent": "my-app/1.0"}
if api_key:
headers["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);"
)
print(out["status"], out["result"])Set a User-Agent if you use stdlib `urllib`
The API sits behind a WAF that rejects the default Python-urllib/3.x agent with a
403 and the body error code: 1010. It reads like an auth failure and is not — the
identical request with any other agent returns 200.
Set one, as above, or use requests / httpx / aiohttp, whose own agents are all
fine. This is the single most common first-run failure from Python.
curl -s "https://api.bowmark.ai/v1/library?query=flights"curl -s -X POST https://api.bowmark.ai/v1/run \
-H 'Content-Type: application/json' \
-d '{"script":"const { offers } = await bowmark.pcparts.search(\"RTX 5080\"); return offers.slice(0,3);"}'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. Add
-H "Authorization: Bearer $BOWMARK_API_KEY" to either endpoint to raise the cap.
What exists to call is also listed one row per function in
CAPABILITIES.md and
PROVIDERS.md.
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. |
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.
Signed-in runs need a key
Without an API key on the request you are told to add one rather than handed a handoff link. See API keys.
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.