# Scripting (/docs/scripting)

import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { Callout } from "fumadocs-ui/components/callout";

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

<Tabs items={["Node / TypeScript", "Python"]}>
  <Tab value="Node / TypeScript">
    ```sh
    npm i @bowmark/web
    ```

    [`@bowmark/web`](https://www.npmjs.com/package/@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](https://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=…`](#no-install-at-all), or the
    [`CAPABILITIES.md`](https://raw.githubusercontent.com/bowmark-ai/web/main/CAPABILITIES.md) and
    [`PROVIDERS.md`](https://raw.githubusercontent.com/bowmark-ai/web/main/PROVIDERS.md) lists. What the
    package gives you is `bowmark.<capability>.<fn>()` and `bowmark.providers.<site>.<fn>()`.

    <Callout type="warn" title="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:

      ```sh
      npx tsx app.ts     # or: bun app.ts
      ```

      A 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](#no-install-at-all) instead.
    </Callout>
  </Tab>

  <Tab value="Python">
    ```sh
    pip install bowmark-web bowmark-web-stubs
    ```

    Requires **Python 3.11 or newer**. [`bowmark-web`](https://pypi.org/project/bowmark-web/) — MIT, **zero runtime
    dependencies**; `urllib` and `json` are the whole transport.

    The second package is the types.
    [PEP 561](https://peps.python.org/pep-0561/) 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.
  </Tab>
</Tabs>

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

<Tabs items={["Node / TypeScript", "Python"]}>
  <Tab value="Node / TypeScript">
    ```ts
    import { bowmark } from "@bowmark/web";

    const { tracks } = await bowmark.music.search("aphex twin", 3);
    console.log(tracks[0].title);
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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())
    ```
  </Tab>
</Tabs>

Both read your key from `BOWMARK_API_KEY`. Every call needs one: sign up at
[bowmark.ai/sign-up](https://bowmark.ai/sign-up) and create a key at
[bowmark.ai/dashboard/keys](https://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 separate instances and two cookie jars**, so a cart the first filled does not exist
for the second.

<Tabs items={["Node / TypeScript", "Python"]}>
  <Tab value="Node / TypeScript">
    ```ts
    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
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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 1
    ```
  </Tab>
</Tabs>

**Reach 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.

<Callout type="warn" title="`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`](#no-install-at-all) and you get `ReferenceError: session is
    not defined` — the sandbox's globals are
  [`bowmark`, `log`, `URL`, `URLSearchParams`](#what-you-get) 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 instance, 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.
</Callout>

### 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](https://bowmark.ai/dashboard/keys) and set it                                                                                                                                             |
| `merchant_verification_failed` | `browser_agent` could not confirm the site it reached is the one you asked for. This is a deliberate safety check, not a bug — see [When nothing else works: the browser agent](#when-nothing-else-works-the-browser-agent). | Retrying with a more specific URL is unlikely to help if the site itself is blocking bots. Check whether the site has bot defenses (WAF, anti-scraping) and whether you can use a provider or `read.page` instead of `browser_agent`. |
| `rung_undeclared`              | The unit decides per call how to load each page, so `session()` cannot set it up ahead of time. `read.page` and `read.pages` — the documented fallback when a site has no provider — are the units that do this.             | Call it through `run()` instead: `await run("const page = await bowmark.read.page(url); return page.content;")` — a run decides what each page needs as it goes                                                                       |
| `run_only`                     | The unit is served only by `run()`, 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](#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.

<Callout title="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.
</Callout>

### 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.

<Tabs items={["Node / TypeScript", "Python"]}>
  <Tab value="Node / TypeScript">
    ```ts
    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"
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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"
    ```
  </Tab>
</Tabs>

It returns the [envelope](#reading-the-response) 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.

<Callout type="warn" title="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](#when-nothing-else-works-the-browser-agent).

  That is the most any run gets. A script that makes only a few quick calls is given less,
  sized to the calls it makes. If one of those is cut off early the error says so, and sending
  the same script again gives it the full time.
</Callout>

<Callout type="info" title="Too many runs at once, and a busy platform">
  One account can have a limited number of runs queued or running at the same time
  (currently 16). Past it, `POST /v1/run` answers `429` with a `Retry-After` header and a
  `meta.concurrency` object (`limit`, `inFlight`, `retryAfterSeconds`). Nothing started and
  nothing was charged, so wait and send fewer at a time rather than repeating the whole batch.
  The 90 s is counted from when your request arrives, so a run that waited for a free slot has
  less of it left: `queuedMs` in the response says how long it waited (it appears when that
  was a second or more). If no slot frees up within 45 s, the error says either
  `every slot is busy` (a queue: send fewer) or `the executor service is down` (an outage on
  our side). In both the run did not start, so sending the same script again later is safe.
</Callout>

### No install at all

The client is a convenience over two HTTP endpoints. Anything that can POST JSON can use
Bowmark with nothing installed.

<Tabs items={["Node", "Python", "curl"]}>
  <Tab value="Node">
    ```js
    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();
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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"])
    ```
  </Tab>

  <Tab value="curl">
    ```sh
    curl -s "https://api.bowmark.ai/v1/library?query=flights" \
      -H "Authorization: Bearer $BOWMARK_API_KEY"
    ```

    ```sh
    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);"}'
    ```
  </Tab>
</Tabs>

<Callout type="warn" title="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](#no-install-at-all) shows this.

  **If you use `requests`, `httpx`, `aiohttp` or the `@bowmark/web` package:** You are not
  affected. Their User-Agent strings are all fine.
</Callout>

`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`](https://raw.githubusercontent.com/bowmark-ai/web/main/CAPABILITIES.md) and
[`PROVIDERS.md`](https://raw.githubusercontent.com/bowmark-ai/web/main/PROVIDERS.md).

**Bowmark itself has no scheduling, watch, cron or alert primitive — every run is
one-shot.** There is no capability in the [library](#no-install-at-all) 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](#no-install-at-all), called by a no-code tool instead of your own code.

<Tabs items={["Zapier", "Make"]}>
  <Tab value="Zapier">
    1. **Trigger** — "Schedule by Zapier", set to weekly.
    2. **Call Bowmark** — a "Webhooks by Zapier: POST" step to `https://api.bowmark.ai/v1/run`,
       with header `Authorization: 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):
       ```json
       { "script": "const { flights } = await bowmark.flights.search({ from: 'OSL', to: 'AMS', depart: '2026-10-01' }); return flights;" }
       ```
    3. Zapier parses the JSON reply automatically; `result` is the array this run found.
    4. **Loop over `result`** with "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.
  </Tab>

  <Tab value="Make">
    1. **Trigger** — a "Schedule" module, weekly.
    2. **Call Bowmark** — an "HTTP: Make a request" module, `POST` to
       `https://api.bowmark.ai/v1/run`, header `Authorization: Bearer <your BOWMARK_API_KEY>`,
       body type JSON:
       ```json
       { "script": "const { flights } = await bowmark.flights.search({ from: 'OSL', to: 'AMS', depart: '2026-10-01' }); return flights;" }
       ```
    3. Make parses the response; `result` is the array this run found.
    4. **Iterate `result`** with 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.
  </Tab>
</Tabs>

**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.

<Callout title="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](#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.
</Callout>

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](#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.

<Tabs items={["Zapier", "Make"]}>
  <Tab value="Zapier">
    1. **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.
    2. Steps 2-4 are the same "Call Bowmark" / parse / loop-into-Airtable steps as the
       scheduled recipe above.
    3. 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.
  </Tab>

  <Tab value="Make">
    1. **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).
    2. Steps 2-4 are unchanged from the scheduled recipe.
    3. 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.
  </Tab>
</Tabs>

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.

```js
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 state, cookie jar and cart; two separate runs do not. So the multi-step
flow that needs [`session()`](#several-calls--use-session) from your own code needs nothing
here — sequential `await`s are the session. `session()` itself is not a global and cannot be
sent as part of a script.

<Callout type="warn" title="`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=…`](#no-install-at-all).
</Callout>

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`, no `XMLHttpRequest`
* No filesystem, no `process`
* No `import`, no `require`

The sandbox has hard CPU, memory and wall-clock limits, so keep scripts small and
deterministic. No infinite loops.

<Callout title="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.
</Callout>

### 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.

```js
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.

1. `needs` lists the sites; `meta.handoff.url` is a single-use link that expires.
2. Give the user that URL, say which sites it covers, and **wait**.
3. 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.

1. **`list_secrets` and `request_secret` are 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 no `bowmark.request_secret(...)` to call from inside
   `run()` or `session()`. From the agent's chat: `list_secrets` checks whether a name is
   already set, and `request_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. (ChatGPT's
   `/mcp/chatgpt-app` connector does not offer these three tools: the user adds the secret on
   their [secrets page](https://bowmark.ai/dashboard/secrets) instead.)
2. **`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.

```js
// 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.

<Callout type="warn" title="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](#when-nothing-else-works-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=…`](#no-install-at-all) for the site before you plan around a
  stored credential.
</Callout>

## Is it the site, or is it Bowmark?

A call that never reached its site fails the same way whether that one site is refusing
us or Bowmark's own network is having trouble: `status: 0` from `read.page` ("the request
never completed (dns, timeout or refused)"), `HTTP 0` from a provider, or a network error
such as `net::ERR_TUNNEL_CONNECTION_FAILED`. The message cannot tell you which. A read of
a page that never blocks anyone can:

```js
const probe = await bowmark.read.page("https://example.com");
return probe.status;
```

* **`200`** — Bowmark is reaching the web, so the problem is the site you were calling:
  it is refusing us or is down itself. Retrying straight away rarely changes that.
* **`0` as well** — the problem is on Bowmark's side, not the site's, and nothing is
  wrong with your script. Try again in a few minutes. There is no public status page
  yet, so this read is the check.

## `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

```js
// 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) reads any page — fast where the page allows it, with a fuller load only where the
page needs one. It is the documented fallback for exactly this case, not a workaround.
When a page is behind a CAPTCHA, `read.page` tries to solve it first — the kinds it solves, and
what each solve costs, are under [Captcha solves](/docs/pricing#captcha-solves). One it
cannot clear comes back as `ok: false` with `wall: { vendor, cleared: false }` and a warning
naming it. `read.page` never pauses for a person; for a page that needs one, use
[`browser_agent`](#when-nothing-else-works-the-browser-agent), which returns `needs_input`.

**Call it through `run()` — never `session()`, and never the bare top-level `bowmark`
client, which opens a session internally even for one call.** How it loads each page is
decided per call rather than declared up front, so both refuse it with `code: "rung_undeclared"`
— see [What it throws](#what-it-throws).

```js
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`                                                                                                                                                                                                                                                                    |
| `warnings`             | Why a read is thin: a shell, an uncleared bot wall (named), the page's own data request failing behind a shell that still rendered (its own "No items." or similar — only flagged when the rendered page is itself small; a same-site telemetry call failing beside a page full of real content is not this), 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                                                                                                                                                                                                                                                                                                                             |
| `escalationReason`     | Why `content` may be incomplete — the page needed a fuller load than this read gave it (always set under `strategy: "fetch"` when so). `null` when nothing is known to be missing                                                                                                                                                                                                                        |
| `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:

```js
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.

### Reading a listing that spans multiple pages

A listing — search results, a job board, a product catalogue — often has pagination.
`read.page` returns no `links` field — markdown is the default `format`, so a link
on the page is already in the `content` string as `[text](url)`. Do not guess the
pagination parameter; instead, read the page as markdown and pull the next-page link
out of that text with a regex:

```js
await run(`
  const allRows = [];
  let url = "https://example.com/products";
  
  while (url) {
    const page = await bowmark.read.page(url);
    if (!page.ok) break;  // wall or error — stop
    
    // Extract product rows from this page's content
    const rows = page.content.match(/Product: (.*?)\\n/g) || [];
    allRows.push(...rows);
    
    // Find the next-page link among the markdown links in this page's content
    const linkPattern = /\\[([^\\]]*)\\]\\(([^)]+)\\)/g;
    let nextUrl = null;
    for (const [, text, href] of page.content.matchAll(linkPattern)) {
      if (text.toLowerCase().includes("next") || text === ">") {
        nextUrl = new URL(href, page.url).toString();
        break;
      }
    }
    url = nextUrl;  // continue if there is a next link
  }
  
  return allRows;
`);
```

The key: **check the returned \`title\` and \`url\` to verify the page actually changed**, rather than assuming a parameter like \`?page=2\` exists or works as expected. Many sites redirect a wrong pagination parameter back to page 1 silently, so verifying the title guards against looping on the same page forever.

Full walkthrough: [How it works § No provider? Read any page
directly](/docs/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.

<Callout type="warn" title="Not for daily jobs or repeating checks">
  The browser agent is for **one-off tasks only** — it cannot run on a schedule, monitor a page
  for changes, or check something daily. It is one task, a few minutes, then it stops.

  For a weekly check, a daily monitor, or any repeating job: use a capability or `read.page`
  inside a script that **you** schedule with Zapier, Make, GitHub Actions, or cron — that is
  the recurring part, and Bowmark is the one call inside it. See [Running it on a
  schedule](#running-it-on-a-schedule-with-no-code--zapier--make).
</Callout>

**It is scoped to a transactional task on a recognizable merchant site — not a general
fallback for a plain read `read.page` can't handle.** Before it acts, it confirms it has
landed on the merchant the task names; a plain read on an arbitrary non-commerce site fails
fast with `merchant_verification_failed: could not confirm this is the correct merchant site`,
by design, not as a bug. For a page `read.page` can't reach, try `read.page` with a more
specific URL or `{ format: "html" }` first — reach for `browser_agent` when the task is
genuinely a booking, form fill or login, not as the next thing to try after a read fails.

<Callout type="info" title="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](https://bowmark.ai/dashboard/billing) as `browser_agent.vendor`. [Estimating
  costs](/docs/pricing#hosted-browser-agents) has the per-minute browser rates and a worked
  example so you can size a run before you start it.
</Callout>

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.

```ts
// ✗ 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 })})`);
```

**Your account can hold at most 3 open sessions at once.** A finished or failed session still holds a slot
until you call `bowmark.browser_agent.stop()` on it. Use `bowmark.browser_agent.list()` to see what you're
holding, and pass the session id to `stop()` when you're done — a 20-minute idle timer will eventually close
it, but do not rely on that.

**Extracting structured data with `outputSchema`**: When you need the browser agent to extract data as JSON
that matches a specific structure, pass a JSON Schema in `outputSchema`. The agent will ensure its result
conforms to the schema, validating each field and skipping or remapping data that doesn't fit. This is
particularly useful for tasks like extracting listings, product details, or comparable data from a page.

```ts
// Without outputSchema — the agent returns free text
const { result: description } = await run(`
  return bowmark.browser_agent.start({
    task: "Extract all apartment listings from the search results, including price, bedrooms, and location"
  });
`);
// result might be: "Found 5 apartments. 1 bed, $1200, Downtown. 2 bed, $1800, Uptown. ..."

// With outputSchema — the agent returns validated JSON
const { result: apartments } = await run(`
  return bowmark.browser_agent.start({
    task: "Extract all apartment listings from the search results as JSON",
    outputSchema: {
      type: "object",
      properties: {
        listings: {
          type: "array",
          items: {
            type: "object",
            properties: {
              price: { type: "number", description: "Monthly rent in USD" },
              bedrooms: { type: "number" },
              location: { type: "string" },
              url: { type: "string" }
            },
            required: ["price", "bedrooms", "location", "url"]
          }
        }
      },
      required: ["listings"]
    }
  });
`);
// result is now: { listings: [{ price: 1200, bedrooms: 1, location: "Downtown", url: "..." }, ...] }
```

The schema is validated after the agent finishes its task. If the result doesn't match, the agent reports
which fields were problematic — this is your signal to refine the task description or loosen the schema
constraints. **Do not pass `outputSchema` when you want free-text output** — it adds latency and may force
the agent to structure things that are better left prose.

**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 45
seconds (the `waitMs` parameter, capped there to leave headroom under the point — commonly
around a minute — where most chat clients kill a tool call), so two `status()` calls back-to-back
consume 90 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.

**Set your own give-up deadline at 5 minutes or more, not at 2.** Identical tasks do not
take identical time — the agent re-explores the page each run — so 90 seconds once and
past 120 the next is normal variance, not a regression. The 5-minute ceiling below is the
bound Bowmark enforces for you; a client deadline shorter than it (110s and 120s are the
usual picks, borrowed from the per-`run()` cap) abandons runs that were about to finish.

**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. That `failed` and its `error` hold on every later
`status()` call, so you do not have to catch the exact poll that cut it off; a final step
reading "Run is cancelled. Exiting." is that same cut-off, not a separate failure. The
session stays open: `send()` a narrower instruction to try again in the same browser, or
`stop()` it. A long form with many repeated inputs (seven travellers' ages, say) is the
usual way to hit it — split it, or `send()` the next part once the first is filled. This is a fixed Bowmark-side
ceiling, not something you configure — there is no way to raise or lower it per call.

**A site that blocks the browser outright also reports `failed`, and quickly.** When the
agent hits an anti-bot "access denied" page (Akamai, Cloudflare, PerimeterX, Imperva,
DataDome, or similar) rather than an interactive login or a CAPTCHA, it stops itself —
usually well inside its normal turn length, not at the 5-minute ceiling above — and
`error` begins `blocked: `, e.g. `blocked: akamai access denied`. It never reads
`needs_input` for this: nobody can take over and clear a bot wall the way they can a
login. Treat it as a hard failure for that site — retrying with `send()` or a fresh
`start()` will not get past a wall that already stopped the agent.

**`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`) — what YOU are charged, not what the vendor is paid. How many
runs that buys depends on task length and complexity, since there is no flat per-run
price; see [Estimating costs](/docs/pricing#hosted-browser-agents) for the per-minute
rates and a worked example to size your own budget.

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

```ts
// 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: 45000 })`);
  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("…")`](#storing-a-login-for-an-unattended-script) 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`. **How soon
that `needs_input` arrives varies from run to run** — seconds on one run, a minute or more on
the next identical one — because the agent decides for itself when it is stuck. It is told to
hand off after about 15 seconds without progress, so most runs flip quickly, but a `running`
status on a login page is not by itself a hang: keep polling under the same 5-minute give-up
deadline as any other task. A turn that never hands off is cancelled at that ceiling and reads
`failed`; the session stays open, so `send()` it an instruction to stop and ask for the login.
**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()`.

<Callout type="warn" title="You cannot log in once with the browser agent and read the rest cheaply with `read.page`">
  They share no 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` keeps no
  login state of its own. 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](#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](/docs/pricing#hosted-browser-agents) — `read.page` cannot inherit that login, so it
  cannot substitute for the agent on pages that need one.
</Callout>

<Callout type="warn" title="`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.
</Callout>

<Callout type="info" title="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.
</Callout>

**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](https://bowmark.ai/dashboard/billing) as `browser_agent.vendor`. See
[Pricing § Hosted browser agents](/docs/pricing#hosted-browser-agents) for the per-minute
rates and a worked example to estimate 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.
