Do things on live websites: prices, availability, quotes, bookings, anything behind a form or login.
ai.bowmark/bowmark (MCP Server)
The ai.bowmark/bowmark MCP server enables performing actions on live websites, including prices, availability, quotes, and bookings. It can also handle workflows behind a form or login. The server exposes 4 tools and is described as a “bowmark skill” for MCP installations.
🛠️ Key Features
Works on live websites
Supports actions such as prices, availability, quotes, and bookings
Handles tasks behind forms or login
Provides 4 tools
🚀 Use Cases
Retrieve current pricing and availability from live sites
Generate or request quotes
Book services through website flows
Automate interactions that require form submission or authentication
⚡ Developer Benefits
Defines MCP tool access via a named server: bowmark
Installable via Cursor and VS Code (installation links included in the readme excerpt)
⚠️ Limitations
Specific tool names, authentication method details, and supported site types are not provided in the provided excerpt
**Use this whenever a task touches a live website.** It answers, definitively and cheaply, whether Bowmark can already DO the thing: look up current prices, check real availability or stock, search a site, get a quote or a fare, drive a configurator, start a booking, or pull anything that only exists behind a form or a filter. **Behind a LOGIN is narrower**: it works once a purpose-built provider exists for that site, or the caller has a saved connection there — a generic caller with neither gets only a form-field reader, which cannot pass an identity check or obtain a vendor API key on its own.
**Use this for Bowmark questions too.** Before answering how to write or run a Bowmark script, what the sandbox supports, or how to start when no site or task is named, call it with the caller's words. The matching entry gives the platform guidance; guessing from general programming knowledge does not.
**Checking is cheap, so check.** One read-only call, no site is touched, and an unrecognized query returns a one-line index instead of an error, so the check never dead-ends and never costs you an attempt. If nothing fits, you have lost one cheap call and can use your normal approach.
What comes back is the callable **function library** you write against: the runtime globals (`log`) PLUS, for each capability your query named, its namespace, TypeScript types, functions, and worked examples. Everything listed is real and callable. The language rules and how to run a script are on the `run` tool description.
Pass `query` — what you want to DO (`"flights"`, `"price a GPU"`) or, if you have one in mind, the COMPANY or site (`"Kayak"`, `"newegg.com"`). If the caller gave only a URL, use its hostname as the query — the query parameters identify a page state, not the company. A phrase in the user's own words is fine; it is matched against the whole library. **You get what you asked about and nothing else.** If nothing matches — or you send no query — you get instead a one-line index: pick whichever entry fits and CALL AGAIN with its name to get the types and examples you need to write a script.
**Every response is bounded, and it says so when it is a slice.** A broad query can match more than one response carries; when that happens the answer opens with a partial-answer line naming what it left out. **Read it before concluding anything** — absence from a sliced list means nothing, and the fix is one narrower query (a single task, or a single company by name), which always returns that entry in full. Only an answer that does NOT say it is a slice supports the conclusion that a task is uncovered.
**THREE tiers come back, and the third is not written by Bowmark at all.** SITE TOOLS (`bowmark.sites["skims.com"].search_catalog(...)`) are the tools a SITE publishes about ITSELF — its own MCP server, or the tools its pages register — read live at the moment you name the host and callable like anything else. Nothing has to have been built for that site in advance, so **naming a site is worth doing even when you expect nothing to exist for it.** The descriptions are the site's own words, every tool it exposes is callable (including ones that change something there), and site tools are free.
**The other two tiers come back too.** CAPABILITIES (`bowmark.flights.search(...)`) are the default and usually what you want: one call fans out across several sites, dedupes, ranks, and routes around a site that's failing. PROVIDERS (`bowmark.providers.kayak.search(...)`) are the individual sites, callable directly — they appear only when your query NAMED a company, or when the capability has just one provider behind it. A direct provider call gets that site's own raw shape and no failover, so prefer the capability unless you specifically want that site.
Loop: call `get_library` → write a JS script against the `bowmark` global → send it to `run`.
Parameters1
query
string
optional
Optional, but pass one — it decides how much detail comes back. What the user wants to do, in their words ("flights", "price a GPU", "book a table", "check stock"), or a company/site if they named one ("Kayak", "newegg.com"). If they supplied only a URL, pass its hostname rather than the full URL or its query parameters — and a HOSTNAME is worth passing even for a site you expect nothing to exist for, because that is what makes Bowmark look for the site's own tools. A rough guess is always safe: a value that matches nothing returns the one-line index rather than an error, and so does omitting it.
Raw schema
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Optional, but pass one — it decides how much detail comes back. What the user wants to do, in their words (\"flights\", \"price a GPU\", \"book a table\", \"check stock\"), or a company/site if they named one (\"Kayak\", \"newegg.com\"). If they supplied only a URL, pass its hostname rather than the full URL or its query parameters — and a HOSTNAME is worth passing even for a site you expect nothing to exist for, because that is what makes Bowmark look for the site's own tools. A rough guess is always safe: a value that matches nothing returns the one-line index rather than an error, and so does omitting it."
}
},
"additionalProperties": false
}
run
**Executes the task on the real websites** (the search, the price check, the availability lookup, the configurator, the booking flow) and returns what came back. Runs a script you authored against the `get_library` vocabulary, on the live sites, and returns `{ ok, result, logs, error, ms }`. Call `get_library` FIRST — it gives the exact function names, argument shapes, and return types; this description is the LANGUAGE + how-to (get_library is just the vocabulary).
THE LANGUAGE — plain async JavaScript:
• `bowmark` is a ready global (no import). Call capabilities off it — `await bowmark.<capability>.<method>(...)` — always `await`, they're async.
• Individual sites are callable too, at `await bowmark.providers.<provider>.<fn>(...)`. Use one when you specifically want THAT site; otherwise prefer the capability, which fans out across sites and routes around failures.
• Real control flow: `await`, `if`, loops, array methods (`map`/`filter`/`sort`/`slice`), and `Promise.all` for fan-out.
• `return` a value to get it back (JSON-serialized). `log(...)` for progress lines.
• Standard JavaScript built-ins are there (`JSON`, `Math`, `Date`, `RegExp`, `Intl`, `Promise`), plus `URL` and `URLSearchParams` — use them to resolve a relative link against the page it came from and to build query strings. Nothing else from the Web platform exists: no `fetch`, `setTimeout`, `TextEncoder` or `crypto`.
• `bowmark` is the ONLY I/O — no `fetch`, `process`, filesystem, or `import`/`require`. Write a plain async body, not a wrapping function.
• `bowmark.files` keeps a file the run produces (a CSV, an image, a transcript) in the caller's Bowmark account and gives you a link to it: `save({ name, text | base64, contentType? })` → `{ id, url, expiresAt }` (25 MB from a script), `list()`, `get(id)`, `url(id, { expiresIn })`, `makePublic(id)` → `{ publicUrl }`, `makePrivate(id)`, `delete(id)`. Return the link rather than inlining the bytes — UNLESS you need to read the text yourself (a transcript you will summarize): then return the part you need, trimmed (e.g. `text.slice(0, 4000)` per item), beside the link in the SAME run. Fetching each saved link back afterwards costs one tool call per file (measured 2026-10-02: 15 transcripts, ~100s). Keep the whole result under ~25,000 characters: clients reject or spill a larger tool result (Claude Code moved a 50 KB one to a file the model could not open), and the answer is lost.
• Keep scripts small and deterministic — no infinite loops. Runs in a hard sandbox with CPU + memory + wall-clock limits.
**Your own tool-call budget is tighter than you'd guess, and it decides how many calls fit in one script.** Most MCP clients time a single tool call out at around 55 seconds, and ONE ordinary capability call already spends 30-55 seconds of that fanning out to live sites — see COMPOSITION below before calling a second capability in the same script.
SENDING IT: pass the script text as `run({ script })` — there is no `site` argument; the library exposes every capability under `bowmark`. A run still going after ~40 seconds answers `status: "running"` with its `runId` instead of letting your tool call time out: it keeps executing on Bowmark's side, so call `run({ runId })` to wait for that SAME run — never send the script again, which would do the work twice. `result` is whatever you returned; `logs` are your `log()` lines in order; on a throw/timeout `ok:false` and `error` is set.
CHECK `status` BEFORE `ok`. It is `ok` | `error` | `partial` | `needs_user` | `running` (call `run({ runId })` to collect it).
• `partial` means the script RAN and `result` is real and usable, but some of what it called never answered — so the result is narrower than what you asked for. `ok` is still `true`; this is not a failure. `incomplete.summary` says what happened in one sentence, `incomplete.failures` names each call that threw and what the site said, and `incomplete.degraded` names each call that answered while reporting its OWN results thin. You MUST say so when you present the result: name what was missed, and do not describe it
Parameters2
script
string
optional
The JavaScript script body to execute (async, against the `bowmark` global). e.g. `const { flights, warnings } = await bowmark.flights.search({from:'SFO',to:'JFK',depart:'2026-09-01'}); return { best: flights.sort((a,b)=>a.price-b.price)[0], warnings };`
runId
string
optional
Only to collect a run an earlier call answered `status: "running"` for: pass its `runId` and no `script`, and this call waits for that same run's result.
Raw schema
{
"type": "object",
"properties": {
"script": {
"type": "string",
"description": "The JavaScript script body to execute (async, against the `bowmark` global). e.g. `const { flights, warnings } = await bowmark.flights.search({from:'SFO',to:'JFK',depart:'2026-09-01'}); return { best: flights.sort((a,b)=>a.price-b.price)[0], warnings };`"
},
"runId": {
"type": "string",
"format": "uuid",
"description": "Only to collect a run an earlier call answered `status: \"running\"` for: pass its `runId` and no `script`, and this call waits for that same run's result."
}
},
"additionalProperties": false
}
report
Record what was missing, wrong, or incomplete so Bowmark can build it. Pass the `runId` returned by `run` when this report is about a run; omit it when `get_library` did not cover the task. This records feedback only and does not retry a run.
Parameters2
report
string
required
What Bowmark could not do or returned incorrectly.
runId
string
optional
Optional `runId` returned by `run` for the result you are reporting.
Raw schema
{
"type": "object",
"properties": {
"report": {
"type": "string",
"minLength": 1,
"maxLength": 4000,
"description": "What Bowmark could not do or returned incorrectly."
},
"runId": {
"type": "string",
"format": "uuid",
"description": "Optional `runId` returned by `run` for the result you are reporting."
}
},
"required": [
"report"
],
"additionalProperties": false
}
list_secrets
List the stored secrets this account holds, by NAME. Returns name, type, which hosts each may be used against, when it expires and when it was last used — never a value, and no Bowmark endpoint returns one.
**Call this BEFORE `request_secret`.** A secret the user has already set is ready to use; asking for it again sends them to a page for nothing. In a script, refer to one by name: `bowmark.secret('acme_pw')`.
Parameters
No parameters.
Raw schema
{
"type": "object",
"properties": {}
}
request_secret
Create a named, empty slot for a secret and get back a link the user opens to fill it in. They type the value on a Bowmark page and choose how long it lives, from 5 minutes to never. It is encrypted in their own browser before it leaves, so nothing on the way — this connection included — ever sees it.
**Give the user the link. Never ask them to type a password, API key or one-time code to you.** A secret in this conversation is in your context, in the transcript and in the logs.
**Name it for the person, not for your script.** The name you pass is the heading on the page they open and the row they see in their secret list months later, so make it `<site>_<what it is>`: `letterboxd_password`, `stripe_api_key`, `acme_totp_seed`. A run id, a timestamp, a uuid or a bare `password` is refused.
Call `list_secrets` first: if the name already exists and is set, use it instead. `name` is lowercase letters, digits, `_`, `.` and `-`. `hosts` narrows where the value may be used and is worth passing — a secret is refused against any other site.
Parameters3
name
string
required
Lowercase name, `<site>_<what it is>` — `letterboxd_password`, `stripe_api_key`, `acme_totp_seed`. Letters, digits, _ . - only. THE USER READS THIS: it is the heading on the page they open and the row in their credential list months later, so use words, never a run id, a timestamp or a bare `password`.
type
string
required
What kind of value the person will be asked for.
hosts
array
optional
Hosts the value may be used against, e.g. ["acme.com"]. Refused elsewhere.
Raw schema
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Lowercase name, `<site>_<what it is>` — `letterboxd_password`, `stripe_api_key`, `acme_totp_seed`. Letters, digits, _ . - only. THE USER READS THIS: it is the heading on the page they open and the row in their credential list months later, so use words, never a run id, a timestamp or a bare `password`."
},
"type": {
"type": "string",
"enum": [
"password",
"username",
"totpSeed",
"apiKey",
"recoveryCodes",
"cookie",
"text"
],
"description": "What kind of value the person will be asked for."
},
"hosts": {
"type": "array",
"items": {
"type": "string"
},
"description": "Hosts the value may be used against, e.g. [\"acme.com\"]. Refused elsewhere."
}
},
"required": [
"name",
"type"
],
"additionalProperties": false
}
get_secret_link
Return the dashboard link for one stored secret this account holds, so you can answer "where is my Acme password?" with somewhere to go. **It returns a URL, never a value** — opening it requires the user to be signed in and to confirm who they are, and only their own browser can read what comes back.
Parameters1
name
string
required
The stored secret's name, as `list_secrets` shows it.
List the saved logins this account holds — the `id` to name in a later signed-in call as `{ connection }`, which site, which account, whether it is still good, and when it was last used. A live one means a script can reach that site's signed-in pages with no sign-in step and usually no browser.
Useful before a task that needs an account: a connection marked `needs_reauth` or `expired` is why a run would pause and ask the user to sign in again. Adding a NEW login is still the user's to do, at https://bowmark.ai/dashboard/connections — `delete_connection` only forgets one this account already holds.
Parameters
No parameters.
Raw schema
{
"type": "object",
"properties": {}
}
delete_connection
Forget a saved login by the `id` `list_connections` returned. This deletes Bowmark's own record and cookies for it — it does NOT sign the account out on the site, and it cannot be undone; the user signs in again to get a working connection back.
Only call this when the user asked to remove a saved login. To sign out of a site, use `logout_connection` instead. Never call it to "fix" a connection that is merely stale — `needs_reauth`/`expired` recover with a new sign-in, not a delete.
Sign a saved login out by the `id` `list_connections` returned. Bowmark drops the cookies it held, and where the site supports it (`siteLogout: "supported"` in the reply) the session is ended ON THE SITE too — `siteSignedOut: true` means Bowmark checked the site no longer accepts it. The saved login is KEPT, status `logged_out`, so the user can sign back in to the same entry later.
Call this when the user asks to sign out of a site. `delete_connection` is the other action: it forgets the entry entirely and leaves the site session alive.
The skill for Bowmark — the web as callable functions. The agent reads a typed function library, writes a short script against it, and Bowmark runs it on the live sites.
sh
npx skills add bowmark-ai/skill
Works on any host that supports the SKILL.md standard — Claude Code, Codex (which adopted the standard natively), Cursor, GitHub Copilot, OpenCode, and others.
Want both in one step? On Claude Code and Codex, install the plugin instead — it bundles this skill and auto-wires the MCP:
sh
# Claude Code
claude plugin marketplace add bowmark-ai/plugin && claude plugin install bowmark@bowmark-ai
# Codex
codex plugin marketplace add bowmark-ai/plugin # then `codex /plugins` to install
Tools
Tool
What it does
get_library
Read the typed function library for a task or a site. Read-only, touches no site, and an unrecognized query returns the index rather than an error.
run
Execute a short async JavaScript script against the live sites and return { ok, result, logs, error, ms }.
report
Tell Bowmark what it could not do, or which run came back wrong. Recorded as feedback; retries nothing.
No MCP client? The same two calls are plain HTTP — GET https://api.bowmark.ai/v1/library?query=… and POST https://api.bowmark.ai/v1/run, both taking Authorization: Bearer $BOWMARK_API_KEY. The reference is bowmark.ai/docs/api; worked examples are at bowmark.ai/docs/scripting#no-install-at-all.
OpenAI Apps (ChatGPT plugin) variant
The ChatGPT Apps submission takes a skill as a ZIP or folder, and its needs differ slightly from the general skill: the plugin always ships the MCP alongside the skill, and ChatGPT exposes the tools under bare names. So there's a tailored variant, generated from this same canonical skill — never hand-maintained:
Folder:openai/bowmark/ — a content mirror of bowmark/SKILL.md with the trailing "Your API key" section and everything after it dropped (ChatGPT signs in by OAuth and cannot send a key), tool names rewritten to bare get_library / run, and Claude-only frontmatter/tool-name references trimmed. build-openai.mjs is the authority on what it cuts; this line described sections that have never existed in bowmark/SKILL.md, and a newcomer went looking for one.
ZIP:openai/bowmark-openai-skill.zip — the same folder zipped, ready to drop straight into the ChatGPT "Skills" uploader. A committed artifact (not built in CI: the release runner has no zip binary), so it rides the mirror to bowmark-ai/skill at a stable raw URL like any other file.
Both are generated by build-openai.mjs. Edit bowmark/SKILL.md, then regenerate and commit — never edit the openai/ copies directly (CI's check:openai fails a PR whose text mirror is stale):
Required. Your Bowmark API key. Sign up at https://bowmark.ai/sign-up, then create a key at https://bowmark.ai/dashboard/keys. $10 of usage a month is free.