docs

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                 9946ms

Three 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-ai

Four 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/plugin

Then 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:

  1. Calls get_library with something like "pc parts". No site is touched.
  2. Calls run with a few lines of JavaScript it wrote against what came back.
  3. 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.

  • warnings names every site dropped from the fan-out: it failed, or it timed out. A populated warnings means a site was never heard from at all.
  • trace has one row per site regardless, with what each one returned. A row reading empty is 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 BowmarkNeedsUserError with 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_agent if 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 case read.page can'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): result contains the extracted data — text, values, confirmations
  • For writes (form submissions, uploads): result contains 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?

This page as plain text: /docs/quickstart.md. The whole site as one file: https://bowmark.ai/llms-full.txt. Index of every page: https://bowmark.ai/llms.txt.