From 4777e1a9edbefabf88416fa964eaa06e9c90ed50 Mon Sep 17 00:00:00 2001 From: Christopher Powroznik Date: Sat, 10 Oct 2026 15:55:49 -0700 Subject: [PATCH] feat(connectors): add bowmark connector (2 endpoints) Add library (free) and run (flat per-run rate in US dollars) against the Bowmark HTTP API, plus a web-automation category leaf. Fixtures are recorded against the live API. --- .../endpoints/library/endpoint.test.ts | 102 +++++++++++++++++ .../bowmark/endpoints/library/endpoint.ts | 44 ++++++++ .../endpoints/library/schema/inputs.ts | 16 +++ .../bowmark/endpoints/run/endpoint.test.ts | 106 ++++++++++++++++++ connectors/bowmark/endpoints/run/endpoint.ts | 73 ++++++++++++ .../bowmark/endpoints/run/schema/inputs.ts | 20 ++++ .../bowmark/fixtures/library-flights.json | 22 ++++ connectors/bowmark/fixtures/run-error.json | 27 +++++ connectors/bowmark/fixtures/run-ok.json | 30 +++++ .../bowmark/fixtures/unauthorized-run.json | 34 ++++++ connectors/bowmark/provider.ts | 52 +++++++++ connectors/categories.ts | 7 ++ .../changes/add-connector-bowmark/design.md | 39 +++++++ .../changes/add-connector-bowmark/proposal.md | 56 +++++++++ .../specs/bowmark-connector/spec.md | 39 +++++++ .../changes/add-connector-bowmark/tasks.md | 28 +++++ 16 files changed, 695 insertions(+) create mode 100644 connectors/bowmark/endpoints/library/endpoint.test.ts create mode 100644 connectors/bowmark/endpoints/library/endpoint.ts create mode 100644 connectors/bowmark/endpoints/library/schema/inputs.ts create mode 100644 connectors/bowmark/endpoints/run/endpoint.test.ts create mode 100644 connectors/bowmark/endpoints/run/endpoint.ts create mode 100644 connectors/bowmark/endpoints/run/schema/inputs.ts create mode 100644 connectors/bowmark/fixtures/library-flights.json create mode 100644 connectors/bowmark/fixtures/run-error.json create mode 100644 connectors/bowmark/fixtures/run-ok.json create mode 100644 connectors/bowmark/fixtures/unauthorized-run.json create mode 100644 connectors/bowmark/provider.ts create mode 100644 openspec/changes/add-connector-bowmark/design.md create mode 100644 openspec/changes/add-connector-bowmark/proposal.md create mode 100644 openspec/changes/add-connector-bowmark/specs/bowmark-connector/spec.md create mode 100644 openspec/changes/add-connector-bowmark/tasks.md diff --git a/connectors/bowmark/endpoints/library/endpoint.test.ts b/connectors/bowmark/endpoints/library/endpoint.test.ts new file mode 100644 index 00000000..30db29df --- /dev/null +++ b/connectors/bowmark/endpoints/library/endpoint.test.ts @@ -0,0 +1,102 @@ +import { assert, assertEquals, assertRejects } from "@std/assert"; +import type { Json } from "@shared/core"; +import { fromFileUrl } from "@std/path"; +import { + liveSkip, + loadFixture, + runEndpoint, + testBundle, + testSealedUnit, +} from "@shared/testing"; + +const fixturesDir = fromFileUrl(new URL("../../fixtures/", import.meta.url)); + +Deno.test("bowmark: two endpoints, one auth fn, dollar pool, no consolidate", async () => { + const bundle = await testBundle(); + const ids = Object.keys(bundle.endpoints).filter((id) => + id.startsWith("bowmark#") + ).sort(); + assertEquals(ids, ["bowmark#library", "bowmark#run"]); + + const library = bundle.endpoints["bowmark#library"]; + const run = bundle.endpoints["bowmark#run"]; + assertEquals(library.request.method, "GET"); + assertEquals( + library.request.url, + "https://api.bowmark.ai/v1/monid/library", + ); + assertEquals(run.request.method, "POST"); + assertEquals(run.request.url, "https://api.bowmark.ai/v1/monid/run"); + + assertEquals(library.auth.inject.$fn.key, run.auth.inject.$fn.key); + assertEquals(library.usage.consolidate, undefined); + assertEquals(run.usage.consolidate, undefined); + assertEquals(library.usage.model.kind, "FREE"); + assertEquals(run.usage.model.kind, "PER_UNIT"); + assertEquals(Object.keys(run.usage.credits), ["default"]); +}); + +Deno.test("bowmark#library happy (recorded): JSON envelope, free", async () => { + const unit = await testSealedUnit("bowmark#library"); + const fixture = await loadFixture(`${fixturesDir}library-flights.json`); + const result = await runEndpoint({ + unit, + input: { queryParams: { query: "flights" } }, + mode: "replay", + fixture, + }); + assertEquals(result.httpStatus, 200); + assertEquals(result.isProviderError, false); + assertEquals(result.usage, { credits: {}, evidence: {} }); + const output = result.output as Record; + assertEquals(output.query, "flights"); + assert( + typeof output.library === "string" && + (output.library as string).includes("bowmark.flights"), + "library markdown names the capability", + ); +}); + +Deno.test("bowmark#library: query optional but never empty; unknown keys rejected", async () => { + const unit = await testSealedUnit("bowmark#library"); + const fixture = await loadFixture(`${fixturesDir}library-flights.json`); + const rejected: Record[] = [ + { query: "" }, + { query: "x", bogus: 1 }, + ]; + for (const queryParams of rejected) { + await assertRejects( + () => + runEndpoint({ + unit, + input: { queryParams }, + mode: "replay", + fixture, + }), + Error, + "INVALID_INPUT", + JSON.stringify(queryParams), + ); + } +}); + +Deno.test({ + name: "bowmark#library live (gated on BOWMARK_API_KEY)", + ignore: liveSkip("bowmark"), + fn: async () => { + const unit = await testSealedUnit("bowmark#library"); + const result = await runEndpoint({ + unit, + input: { queryParams: { query: "flights" } }, + mode: "live", + }); + assertEquals( + result.isProviderError, + false, + JSON.stringify(result.output), + ); + assertEquals(result.usage, { credits: {}, evidence: {} }); + const output = result.output as Record; + assertEquals(typeof output.library, "string"); + }, +}); diff --git a/connectors/bowmark/endpoints/library/endpoint.ts b/connectors/bowmark/endpoints/library/endpoint.ts new file mode 100644 index 00000000..d9120260 --- /dev/null +++ b/connectors/bowmark/endpoints/library/endpoint.ts @@ -0,0 +1,44 @@ +import { defineEndpoint, UsageModelKind } from "@shared/core"; +import { zBowmarkLibraryQueryParams } from "./schema/inputs.ts"; + +/** + * GET /v1/library: the callable vocabulary, as Markdown inside JSON. + * + * `Accept: application/json` selects the JSON envelope + * `{library, query, queries}`; without it Bowmark answers raw Markdown. + * Free: a library read touches no website (design D3). + */ +export default defineEndpoint({ + meta: { + displayName: "Bowmark Library", + summary: + "Look up which Bowmark functions cover a task, with their exact types.", + description: "Returns the part of Bowmark's function library that " + + "matches a task or a site: capabilities such as " + + "`bowmark.flights.search` that fan out across several sites, " + + "plus that site's own provider (`bowmark.providers.kayak`) " + + "when you named one, each with argument and return types and " + + "examples, as Markdown in `library`. Touches no website and " + + "is free, so call it first, once per part of the task. A " + + "query that matches nothing returns an index of the whole " + + "library, never an error. Then write a script against these " + + "names and send it to Bowmark /run.", + docsUrl: "https://bowmark.ai/docs/quickstart", + categories: ["web-automation"], + notes: ["Free. A library read never touches a website."], + }, + request: { + method: "GET", + path: "/library", + headers: { Accept: "application/json" }, + }, + input: { + schema: { + queryParams: zBowmarkLibraryQueryParams.extend({ + query: zBowmarkLibraryQueryParams.shape.query.unwrap() + .min(1).optional(), + }), + }, + }, + usage: { model: { kind: UsageModelKind.FREE } }, +}); diff --git a/connectors/bowmark/endpoints/library/schema/inputs.ts b/connectors/bowmark/endpoints/library/schema/inputs.ts new file mode 100644 index 00000000..20218e00 --- /dev/null +++ b/connectors/bowmark/endpoints/library/schema/inputs.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +/** + * GET /v1/library query params (bowmark.ai/docs, 2026-10-10). Mirror + * carries optionality only (D25). Bowmark also accepts `query` repeated + * (one lookup per value, up to 6); this connector exposes the single form. + */ +export const zBowmarkLibraryQueryParams = z.object({ + query: z.string().describe( + 'What the task needs, in plain words ("flights", "price a ' + + 'GPU", "book a table") or a site ("kayak.com", ' + + '"Newegg"). Given only a URL, pass its hostname. A query ' + + "that matches nothing returns a one-line index of the whole " + + "library rather than an error.", + ).optional(), +}).strict(); diff --git a/connectors/bowmark/endpoints/run/endpoint.test.ts b/connectors/bowmark/endpoints/run/endpoint.test.ts new file mode 100644 index 00000000..37ed6154 --- /dev/null +++ b/connectors/bowmark/endpoints/run/endpoint.test.ts @@ -0,0 +1,106 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import type { Json } from "@shared/core"; +import { fromFileUrl } from "@std/path"; +import { + liveSkip, + loadFixture, + runEndpoint, + testSealedUnit, +} from "@shared/testing"; + +const fixturesDir = fromFileUrl(new URL("../../fixtures/", import.meta.url)); +const SCRIPT = 'const page = await bowmark.read.page("https://example.com"); ' + + "return { title: page.title, url: page.url };"; + +Deno.test("bowmark#run happy (recorded): status ok bills one run", async () => { + const unit = await testSealedUnit("bowmark#run"); + const fixture = await loadFixture(`${fixturesDir}run-ok.json`); + const result = await runEndpoint({ + unit, + input: { body: { script: SCRIPT } }, + mode: "replay", + fixture, + }); + assertEquals(result.httpStatus, 200); + assertEquals(result.isProviderError, false); + assertEquals(result.usage, { + credits: { default: 0.04 }, + evidence: { RESULT: 1 }, + }); + const output = result.output as Record; + assertEquals(output.ok, true); + assertEquals(output.status, "ok"); + assertEquals(output.result, { + url: "https://example.com/", + title: "Example Domain", + }); +}); + +Deno.test("bowmark#run failed run (recorded): HTTP 200, status error, bills 0", async () => { + const unit = await testSealedUnit("bowmark#run"); + const fixture = await loadFixture(`${fixturesDir}run-error.json`); + const result = await runEndpoint({ + unit, + input: { body: { script: 'throw new Error("no such flight")' } }, + mode: "replay", + fixture, + }); + assertEquals(result.httpStatus, 200); + assertEquals(result.isProviderError, false); + assertEquals(result.usage, { credits: {}, evidence: { RESULT: 0 } }); + const output = result.output as Record; + assertEquals(output.status, "error"); + assertEquals(output.result, null); +}); + +Deno.test("bowmark#run provider error (recorded 401): data, zero usage", async () => { + const unit = await testSealedUnit("bowmark#run"); + const fixture = await loadFixture(`${fixturesDir}unauthorized-run.json`); + const result = await runEndpoint({ + unit, + input: { body: { script: "return 1" } }, + mode: "replay", + fixture, + }); + assertEquals(result.httpStatus, 401); + assertEquals(result.isProviderError, true); + assertEquals(result.usage, { credits: {}, evidence: {} }); + assertEquals((result.output as Record).ok, false); +}); + +Deno.test("bowmark#run: script required and non-empty; unknown keys rejected", async () => { + const unit = await testSealedUnit("bowmark#run"); + const fixture = await loadFixture(`${fixturesDir}run-ok.json`); + const rejected: Json[] = [{}, { script: "" }, { script: "x", bogus: 1 }]; + for (const body of rejected) { + await assertRejects( + () => + runEndpoint({ unit, input: { body }, mode: "replay", fixture }), + Error, + "INVALID_INPUT", + JSON.stringify(body), + ); + } +}); + +Deno.test({ + name: "bowmark#run live (gated on BOWMARK_API_KEY)", + ignore: liveSkip("bowmark"), + fn: async () => { + const unit = await testSealedUnit("bowmark#run"); + const result = await runEndpoint({ + unit, + input: { body: { script: "return 1 + 1" } }, + mode: "live", + }); + assertEquals( + result.isProviderError, + false, + JSON.stringify(result.output), + ); + const output = result.output as Record; + assertEquals(output.status, "ok"); + assertEquals(output.result, 2); + assertEquals(result.usage.evidence, { RESULT: 1 }); + }, +}); diff --git a/connectors/bowmark/endpoints/run/endpoint.ts b/connectors/bowmark/endpoints/run/endpoint.ts new file mode 100644 index 00000000..9a4c9682 --- /dev/null +++ b/connectors/bowmark/endpoints/run/endpoint.ts @@ -0,0 +1,73 @@ +import { defineEndpoint, Unit, UsageModelKind } from "@shared/core"; +import { zBowmarkRunBody } from "./schema/inputs.ts"; + +/** + * POST /v1/run: execute a script against the live sites. + * + * Synchronous: Bowmark holds the request until the run finishes (its own + * run ceiling is 120 s), so the timeouts sit just above that. + * + * Usage (design D2): PER_UNIT RESULT, one per run that produced a result + * (`status` "ok" or "partial"). An `error` run, a `needs_user` pause and + * every non-2xx bill 0. + */ +export default defineEndpoint({ + meta: { + displayName: "Bowmark Run", + summary: + "Run a short script on live websites and get structured data back.", + description: "Executes a JavaScript body against the `bowmark` " + + "global on Bowmark's machines and returns `{ok, status, " + + "result, logs, error, ms, runId}`. Call Bowmark /library " + + "first for the exact function names. Branch on `status`, " + + 'not `ok`: "ok" is a whole answer; "partial" keeps ok ' + + "true and a real `result` but `incomplete` names what did " + + "not answer, so say the answer is narrower than asked; " + + '"needs_user" means a site needs the account owner signed ' + + "in (`meta.handoff.url` is the link for a human; send the " + + 'same script again afterwards); "error" means there is no ' + + "result and `error` says why. `incomplete.failures[].fixable` " + + "true means your argument was wrong: fix it and rerun. Keep " + + "results small (under ~25,000 characters).", + docsUrl: "https://bowmark.ai/docs/quickstart", + categories: ["web-automation"], + notes: [ + "Flat price per run that returns a result (status ok or " + + "partial). A run that errors or pauses for a sign-in is free.", + ], + }, + request: { method: "POST", path: "/run" }, + input: { + schema: { + body: zBowmarkRunBody.extend({ + script: zBowmarkRunBody.shape.script.unwrap().min(1), + }), + }, + }, + timeouts: { requestMs: 130_000, runMs: 135_000 }, + usage: { + model: { + kind: UsageModelKind.PER_UNIT, + unit: Unit.RESULT, + label: "runs with a result", + description: "runs that finished with status ok or partial", + consumes: { credit: "default", amount: 0.04 }, + }, + /** The hold promises one run. */ + estimate: () => ({ counts: { RESULT: 1 } }), + /** 1 for a run whose `status` is "ok" or "partial", 0 otherwise + * (error, needs_user, or a body that is not an object). */ + evidence: ({ data }) => { + const body = data.output; + if ( + typeof body !== "object" || body === null || + Array.isArray(body) + ) { + return { counts: { RESULT: 0 } }; + } + const status = (body as Record).status; + const done = status === "ok" || status === "partial"; + return { counts: { RESULT: done ? 1 : 0 } }; + }, + }, +}); diff --git a/connectors/bowmark/endpoints/run/schema/inputs.ts b/connectors/bowmark/endpoints/run/schema/inputs.ts new file mode 100644 index 00000000..83bfc828 --- /dev/null +++ b/connectors/bowmark/endpoints/run/schema/inputs.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +/** + * POST /v1/run body (bowmark.ai/docs, 2026-10-10). Mirror carries + * optionality only (D25); the binding requires `script`. + */ +export const zBowmarkRunBody = z.object({ + script: z.string().describe( + "A plain async JavaScript body (not a wrapping function) " + + "written against the `bowmark` global, using the exact " + + "names Bowmark /library returned. `return` the value you " + + "want back; `log(...)` for progress lines. Built-ins plus " + + "URL/URLSearchParams exist; fetch, import, setTimeout and a " + + "filesystem do not: `bowmark` is the only I/O. Run several " + + "calls together with Promise.allSettled, never one after " + + "another. Example: `const { flights } = await " + + "bowmark.flights.search({ from: 'SFO', to: 'JFK', depart: " + + "'2026-11-02' }); return flights.slice(0, 5);`", + ).optional(), +}).strict(); diff --git a/connectors/bowmark/fixtures/library-flights.json b/connectors/bowmark/fixtures/library-flights.json new file mode 100644 index 00000000..3f27ab0b --- /dev/null +++ b/connectors/bowmark/fixtures/library-flights.json @@ -0,0 +1,22 @@ +{ + "name": "library-flights", + "description": "Recorded 2026-10-10 against GET https://api.bowmark.ai/v1/monid/library?query=flights with Accept: application/json. The Markdown `library` string is trimmed by the recorder. Free: zero usage.", + "calls": [ + { + "req": { + "method": "GET", + "url": "https://api.bowmark.ai/v1/monid/library?query=flights" + }, + "res": { + "status": 200, + "body": { + "library": "**Found:** `bowmark.flights`.\n\nAlways in scope, no import: `log(...args)` — progress lines; `URL` and `URLSearchParams`; `bowmark.files` — keep a file the run produces and get a link.\n\n# Capability: Flights (call as `bowmark.flights`)\n\nSearch flights with one call and get back normalized, price-sorted results (the same physical flight appears once). Each result carries the site it came from (`site`) and every site it was found on (`sites`). Returns `{ flights, warnings }` — `warnings` names any", + "query": "flights", + "queries": [ + "flights" + ] + } + } + } + ] +} diff --git a/connectors/bowmark/fixtures/run-error.json b/connectors/bowmark/fixtures/run-error.json new file mode 100644 index 00000000..2594d4a0 --- /dev/null +++ b/connectors/bowmark/fixtures/run-error.json @@ -0,0 +1,27 @@ +{ + "name": "run-error", + "description": "Recorded 2026-10-10 against POST https://api.bowmark.ai/v1/monid/run with a script that throws. HTTP 200 with status error: the call succeeded and the RUN failed, so it bills 0. The account-specific `notice` field was removed.", + "calls": [ + { + "req": { + "method": "POST", + "url": "{{request.url}}", + "body": { + "script": "throw new Error(\"no such flight\")" + } + }, + "res": { + "status": 200, + "body": { + "ok": false, + "status": "error", + "result": null, + "logs": [], + "error": "Error: no such flight", + "ms": 702, + "runId": "82ff2169-7126-4453-ac35-4c8053633f40" + } + } + } + ] +} diff --git a/connectors/bowmark/fixtures/run-ok.json b/connectors/bowmark/fixtures/run-ok.json new file mode 100644 index 00000000..df811704 --- /dev/null +++ b/connectors/bowmark/fixtures/run-ok.json @@ -0,0 +1,30 @@ +{ + "name": "run-ok", + "description": "Recorded 2026-10-10 against POST https://api.bowmark.ai/v1/monid/run: a one-call script reading example.com. status ok, so one billable run. The account-specific `notice` field (a terms-acceptance reminder on the recording account) was removed. Bowmark's per-call `notes` (route narration about how the page was reached) were removed as noise; the replay asserts nothing on them.", + "calls": [ + { + "req": { + "method": "POST", + "url": "{{request.url}}", + "body": { + "script": "const page = await bowmark.read.page(\"https://example.com\"); return { title: page.title, url: page.url };" + } + }, + "res": { + "status": 200, + "body": { + "ok": true, + "status": "ok", + "result": { + "url": "https://example.com/", + "title": "Example Domain" + }, + "logs": [], + "error": null, + "ms": 1698, + "runId": "7ad71481-3f46-4c52-8230-2353a632d016" + } + } + } + ] +} diff --git a/connectors/bowmark/fixtures/unauthorized-run.json b/connectors/bowmark/fixtures/unauthorized-run.json new file mode 100644 index 00000000..234046ab --- /dev/null +++ b/connectors/bowmark/fixtures/unauthorized-run.json @@ -0,0 +1,34 @@ +{ + "name": "unauthorized-run", + "description": "Recorded 2026-10-10 against POST https://api.bowmark.ai/v1/monid/run with an invalid API key. Bowmark answers 401 with a run-shaped envelope (status needs_user) that tells the caller how to get a key. Data, zero usage.", + "calls": [ + { + "req": { + "method": "POST", + "url": "{{request.url}}", + "body": { + "script": "return 1" + } + }, + "res": { + "status": 401, + "body": { + "ok": false, + "status": "needs_user", + "result": null, + "logs": [], + "error": "The Bowmark API key this call presented is no longer valid, so nothing ran. It was not recognised — the key may have been revoked, or deleted from the account that held it.\n\nRetrying will not help, and the key cannot be reinstated. Show your user these steps:\n1. Sign in at https://bowmark.ai/sign-up and open https://bowmark.ai/dashboard/keys.\n2. Check whether the key is still listed there. If it is not, it is gone.\n3. Create a new key and replace the old one where Bowmark is configured:\n - MCP", + "ms": 0, + "meta": { + "accountRequired": { + "signUpUrl": "https://bowmark.ai/sign-up", + "keysUrl": "https://bowmark.ai/dashboard/keys", + "keyRejected": true + }, + "wwwAuthenticate": "Bearer resource_metadata=\"https://api.bowmark.ai/oauth/protected-resource/mcp\", error=\"invalid_token\", error_description=\"A Bowmark account is required.\"" + } + } + } + } + ] +} diff --git a/connectors/bowmark/provider.ts b/connectors/bowmark/provider.ts new file mode 100644 index 00000000..071801c0 --- /dev/null +++ b/connectors/bowmark/provider.ts @@ -0,0 +1,52 @@ +import { defineProvider, presets } from "@shared/core"; + +/** + * Bowmark (bowmark.ai): live websites as a typed function library an agent + * calls from code. Two synchronous JSON endpoints, Bearer auth: + * + * GET /v1/monid/library the callable vocabulary for a task (free) + * POST /v1/monid/run run a short script written against it (billed) + * + * The `/monid/` path segment is Bowmark's attribution channel for this + * listing (a "destination"); the plain `/v1/library` and `/v1/run` answer + * identically. + * + * BILLING (design D2). Bowmark prices a run in US dollars by the resources + * it used (proxy traffic, browser minutes, captcha solves), with a $0.001 + * floor, and settles that into the account's monthly invoice. The run + * envelope deliberately carries NO price, so there is no + * `usage.consolidate` and the derived fold is the bill: a flat per-run + * rate pinned from Bowmark's own measured average (October 2026), on a + * dollar pool (the exa / apify posture). `library` is free. + */ +export default defineProvider({ + name: "bowmark", + meta: { + displayName: "Bowmark", + summary: + "Operate live websites from code: prices, availability, quotes and forms.", + description: "Bowmark turns websites into a typed JavaScript " + + "library an agent calls instead of driving a browser. Ask " + + '/library what the task needs ("flights", "price a GPU", ' + + '"kayak.com") and it returns the exact functions, argument ' + + "shapes and return types; then send /run a short script " + + "against them and get back structured data from the live " + + "site: current prices and stock, search results behind " + + "filters, insurance and shipping quotes, booking " + + "availability, anything behind a form. Capabilities fan out " + + "across several sites and route around a failing one. " + + "Reach for it when the answer depends on what a site shows " + + "right now and no clean API exists.", + homepageUrl: "https://bowmark.ai", + docsUrl: "https://bowmark.ai/docs/quickstart", + categories: ["web-automation"], + }, + auth: { inject: presets.auth.bearer() }, + request: { baseUrl: "https://api.bowmark.ai/v1/monid" }, + timeouts: { requestMs: 30_000, runMs: 30_000 }, + usage: { + /** THE credit system (design D2): Bowmark prices runs in US + * dollars, so the pool IS dollars. */ + credits: { default: { label: "US dollars" } }, + }, +}); diff --git a/connectors/categories.ts b/connectors/categories.ts index 50e6c0e3..6f970290 100644 --- a/connectors/categories.ts +++ b/connectors/categories.ts @@ -317,6 +317,13 @@ export const LEAF_CATEGORIES = defineLeafCategories([ description: "Tools to extract page content, including clean markdown from any URL, and more.", }, + { + id: "web-automation", + displayName: "Web Automation", + description: + "Operate live websites from code: search, filter, fill forms, " + + "and read prices, stock, quotes and availability.", + }, { id: "seo", displayName: "SEO", diff --git a/openspec/changes/add-connector-bowmark/design.md b/openspec/changes/add-connector-bowmark/design.md new file mode 100644 index 00000000..2dea1fd7 --- /dev/null +++ b/openspec/changes/add-connector-bowmark/design.md @@ -0,0 +1,39 @@ +# Design: add-connector-bowmark + +## D1. Two endpoints, the library and the run + +Bowmark's own agent loop is exactly two calls: look up the vocabulary, then +run a script against it. Both are exposed as-is. The run's `script` is the +whole interface; Bowmark validates arguments itself and returns +caller-fixable failures in `incomplete.failures[].fixable`, so no schema is +invented here beyond "a non-empty string". + +## D2. Billing: a pinned dollar rate, no vendor claim + +Bowmark prices each run in US dollars by the resources it used (proxy +traffic, browser minutes, captcha solves) at vendor cost x2, floored at +$0.001, and settles it into the account's monthly invoice. Its run envelope +deliberately does not disclose the price, so there is no receipt to pluck +and no `usage.consolidate`; the derived fold is the bill (the pdl / +keenable posture). + +The pinned rate is $0.04 per run. Bowmark's measured average across paying +accounts in October 2026 was about $0.037 per run (about $0.014 per +function call; a run makes several). A single flat rate over-charges a +trivial run and under-charges a browser-heavy one; it is the honest +average until Bowmark exposes a per-run receipt, at which point a +`consolidate` replaces it and this rate becomes the cross-check. + +Only runs that produced a result bill (`status` ok or partial). An `error` +run and a `needs_user` pause bill 0 here. + +## D3. The library is free + +`GET /library` reads Bowmark's catalog and touches no website, so the model +is FREE. It is the call an agent should make first and often. + +## D4. Timeouts sit above Bowmark's own ceiling + +`/run` is synchronous and Bowmark ends a run at 120 s, so `requestMs` +130 000 / `runMs` 135 000 on the run endpoint; the provider default (30 s) +covers the library read. diff --git a/openspec/changes/add-connector-bowmark/proposal.md b/openspec/changes/add-connector-bowmark/proposal.md new file mode 100644 index 00000000..e7f10a0e --- /dev/null +++ b/openspec/changes/add-connector-bowmark/proposal.md @@ -0,0 +1,56 @@ +# Proposal: add-connector-bowmark + +## Why + +Bowmark (bowmark.ai) turns live websites into a typed JavaScript library an +agent calls from code: current prices and stock, search results behind +filters, insurance and shipping quotes, booking availability, anything +behind a form. It fills the gap between search/scrape providers (which read +a page) and a hosted browser agent (slow, expensive): the agent asks for +the functions a task needs, then runs a short script against them. + +The surface is two synchronous JSON endpoints, Bearer auth, no new engine +capability. + +## What Changes + +- **connectors/bowmark**: 2 endpoints, `presets.auth.bearer()`, base URL + `https://api.bowmark.ai/v1/monid` (`monid` is Bowmark's attribution + segment for this listing; `/v1/library` and `/v1/run` answer identically). + - `bowmark#library` (`GET /library`, `Accept: application/json`): the + functions, argument types and examples that cover a task or a site, as + Markdown in `library`. FREE: a library read touches no website. + - `bowmark#run` (`POST /run`, body `{script}`): runs the script on + Bowmark's machines and returns the run envelope + `{ok, status, result, logs, error, ms, runId}`. Synchronous; Bowmark + holds the request until the run ends (its ceiling is 120 s), so the + timeouts are 130 s / 135 s. +- **One credit pool** `default` ("US dollars"). `run` is PER_UNIT RESULT at + a pinned $0.04 per run that returned a result (`status` ok or partial); + an `error` run, a `needs_user` pause and every non-2xx bill 0. No + `usage.consolidate`: Bowmark's envelope deliberately carries no price + (design D2). +- **New category leaf** `web-automation` ("Operate live websites from code"). + None of the existing leaves covers operating a site (filters, forms, + quotes) as opposed to reading or searching one. +- Fixtures are RECORDED (2026-10-10, live API): a library read, a happy + run, a run whose script threw (HTTP 200, status error), and a 401 on an + invalid key. The account-specific `notice` field and route `notes` were + removed from the recorded bodies. + +## Capabilities + +- `bowmark-connector`. + +## Non-goals + +- The repeated `?query=` form of `/library` (several lookups in one call). +- Bowmark's `needs_user` sign-in relay. A run that needs the account owner + signed in returns `meta.handoff.url` for a human; Monid callers get it as + data and the run bills 0. +- Bowmark's session loop (`/v1/session/*`) and MCP surface. + +## Impact + +New connector tree plus one new category leaf. No schema/engine contract +change: `ENGINE_VERSION` unchanged, no new `Unit`, preset or hook. diff --git a/openspec/changes/add-connector-bowmark/specs/bowmark-connector/spec.md b/openspec/changes/add-connector-bowmark/specs/bowmark-connector/spec.md new file mode 100644 index 00000000..5e7f1ed3 --- /dev/null +++ b/openspec/changes/add-connector-bowmark/specs/bowmark-connector/spec.md @@ -0,0 +1,39 @@ +# bowmark-connector (delta) + +## ADDED Requirements + +### Requirement: Bowmark provider definition +The bowmark provider SHALL declare name `bowmark`, `request.baseUrl` +`https://api.bowmark.ai/v1/monid`, auth `presets.auth.bearer()`, one credit +pool `default` ("US dollars"), and NO `usage.consolidate` (the run envelope +carries no price). + +#### Scenario: A finished run bills the pinned rate +- **WHEN** `POST /run` returns 200 with `status: "ok"` or `"partial"` +- **THEN** usage is `{credits: {default: 0.04}, evidence: {RESULT: 1}}` +- **AND** the run envelope rides through unchanged + +#### Scenario: A failed run is free +- **WHEN** `POST /run` returns 200 with `status: "error"` or `"needs_user"` +- **THEN** usage is `{credits: {}, evidence: {RESULT: 0}}` + +#### Scenario: A library read is free +- **WHEN** `GET /library?query=flights` returns 200 +- **THEN** usage is `{credits: {}, evidence: {}}` +- **AND** output carries `library` (Markdown), `query` and `queries` + +#### Scenario: A 401 is data, zero usage +- **WHEN** `/run` is called with an invalid API key +- **THEN** the run completes as HTTP 401, `isProviderError` true, usage + `{credits: {}, evidence: {}}`, and Bowmark's envelope rides through + +### Requirement: Two endpoints +The connector SHALL provide `GET /library` (`bowmark#library`, +`web-automation`, `Accept: application/json`) and `POST /run` +(`bowmark#run`, `web-automation`). Library input SHALL be strict query +params with an optional non-empty `query`. Run input SHALL be a strict body +with a required non-empty `script`. + +#### Scenario: Script is required +- **WHEN** `POST /run` is called with no `script` or an empty one +- **THEN** the run fails INVALID_INPUT diff --git a/openspec/changes/add-connector-bowmark/tasks.md b/openspec/changes/add-connector-bowmark/tasks.md new file mode 100644 index 00000000..362dcb22 --- /dev/null +++ b/openspec/changes/add-connector-bowmark/tasks.md @@ -0,0 +1,28 @@ +# Tasks: add-connector-bowmark + +## 1. Provider + category + +- [x] 1.1 provider.ts: bearer auth, `api.bowmark.ai/v1/monid` base URL, + 30 s defaults, one `default` pool in US dollars, no consolidate +- [x] 1.2 categories.ts: new leaf `web-automation` + +## 2. Endpoints (2) + +- [x] 2.1 library: GET /library, `Accept: application/json`, strict + query params, FREE (D3) +- [x] 2.2 run: POST /run, strict body, `script` required non-empty; + PER_UNIT RESULT $0.04, 0 unless status ok|partial (D2); 130 s / + 135 s timeouts (D4) + +## 3. Fixtures + tests + +- [x] 3.1 Recorded 2026-10-10: library read, happy run, failed run + (HTTP 200 status error), 401 on an invalid key +- [x] 3.2 Tests: provenance + pool + wire form, happy / failed / 401 / + schema gates, live gated on BOWMARK_API_KEY + +## 4. Wiring + docs + +- [x] 4.1 OpenSpec proposal / design / spec +- [x] 4.2 Verify: fmt · lint · check · test · double-compile · + version:check · catalog smoke