@nekuda/webmcp-sdk 0.7.0-dev.20.1 → 0.7.0-dev.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,29 +1,42 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased — the telemetry key rides the query string
4
-
5
- - A keyed telemetry beacon sends its publishable key as the `key` query parameter
6
- instead of the `x-api-key` header. The header made every keyed beacon a non-simple
7
- request with an `OPTIONS` preflight, and every hosted-snippet install is keyed. The
8
- backend reads both; nothing else changes.
9
-
10
3
  ## 0.7.0 — 2026-09-21
11
4
 
12
5
  One change, made twice: a page load costs the ingest edge one request instead of
13
6
  four. The wire schema stays at `2` and every event still arrives — the two savings
14
- are a header and an envelope, not a sample. **A minor, not a patch**, because the
15
- second of them puts a request shape on the wire that no earlier backend parses: a
7
+ are a request shape and an envelope, not a sample. **A minor, not a patch**, because
8
+ the second of them puts a request shape on the wire that no earlier backend parses: a
16
9
  `batch` is quarantined as `unknown_event` by any transform older than the one that
17
10
  ships with it. The events an install emits, and the fields on them, are byte for
18
11
  byte 0.6.0's.
19
12
 
13
+ ### Documentation
14
+
15
+ - The README is rewritten for people using the package: what it does, install, defining
16
+ and registering tools, page-scoped tools, browser support, the optional tool-call
17
+ analytics, and exactly what the default-on usage telemetry sends, never sends, and how
18
+ to turn it off. The npm description now says what the package does.
19
+
20
20
  ### Telemetry beacons without a preflight
21
21
 
22
22
  - The telemetry beacon is sent as `text/plain` instead of `application/json`. The
23
- body is unchanged; the header makes an anonymous beacon a CORS simple request, so
24
- the browser stops sending an `OPTIONS` preflight before every event. Keyed beacons
25
- still carry `x-api-key` and still preflight. The edge has mapped `text/plain` to
26
- the same template since ADR-0014, so no server change is needed.
23
+ body is unchanged; the content type makes an anonymous beacon a CORS simple
24
+ request, so the browser stops sending an `OPTIONS` preflight before every event.
25
+ The edge has mapped `text/plain` to the same template since ADR-0014, so no server
26
+ change is needed.
27
+ - A **keyed** beacon sends its publishable key as the `key` query parameter instead
28
+ of the `x-api-key` header, because the content type alone was not enough: a custom
29
+ request header is what makes a request non-simple, so a keyed beacon kept paying
30
+ the preflight — and every hosted-snippet install is keyed, since the manifest hands
31
+ it a default publishable key. The backend accepts both and the header still wins,
32
+ so older bundles are unaffected.
33
+ - **This reverses 0.2.0's "not `?api_key=`" for this channel only.** That decision is
34
+ unchanged for `/v1/collect`, which carries the *secret* key and whose authorizer
35
+ declares the header as its identity source. It does not carry to telemetry: the key
36
+ there is *publishable* and already sits in the page's HTML, the soft authorizer
37
+ declares no identity source at all, the stage keeps no access logs, and the WAF both
38
+ redacts the query string from its logs and now disables sampled requests, which
39
+ would otherwise have shown the URI in the console.
27
40
 
28
41
  ### One beacon per page load
29
42
 
package/README.md CHANGED
@@ -1,32 +1,275 @@
1
1
  # @nekuda/webmcp-sdk
2
2
 
3
- Define tools with `defineTool`, then pass them to `registerTools(tools, options)`.
4
- `ready` resolves with initial per-tool outcomes; `unregister()` or `options.signal`
5
- ends the batch. Browsers without a WebMCP surface report `unsupported`.
6
-
7
- ## Page-scoped tools
8
-
9
- Set `pages: ["/products/*"]` on a tool definition to register it only on matching
10
- pages. Missing/empty `pages` means everywhere; lists match any entry. `*` matches
11
- any characters within one segment (`/products/*`, `/product.html*`), `**` zero or
12
- more segments; queries and trailing slashes are ignored on both patterns and locations,
13
- and repeated slashes collapse to one. Hash
14
- routers use patterns such as `/#/product/*`. With no readable location (SSR),
15
- all tools are eligible because the page cannot be evaluated.
16
-
17
- The SDK reconciles tools on `pushState`, `replaceState`, `popstate`, and
18
- `hashchange`, sharing one route listener. If History cannot be patched, registration
19
- still succeeds and navigation detection falls back to `popstate`/`hashchange` only.
20
- A failing route subscriber never escapes into the merchant's History calls or blocks
21
- other subscribers. `registration.current()` lists live
22
- names; `options.onChange(names)` keeps host displays in sync. `ready` covers only
23
- the initially eligible tools. SDK-only `pages` never reaches native `registerTool`.
24
-
25
- The [WebMCP draft](https://webmachinelearning.github.io/webmcp/), checked
26
- 2026-09-10, unregisters via the registration's **AbortSignal**, not
27
- `unregisterTool`; this works on either resolved modelContext global. Legacy
28
- surfaces exposing `unregisterTool(name)` are supported too. A surface that neither
29
- reads the registration signal nor exposes that method keeps a tool once registered;
30
- the SDK reports it as still live rather than claiming removal. Ending a batch always
31
- removes its route subscription. Fixtures under `fixtures/` pin matching semantics
32
- for tests and stay out of the published package.
3
+ Let AI agents use your website. This SDK turns the things your site already does,
4
+ such as searching products, adding to cart or booking a slot, into **tools** that a
5
+ browser agent can call through [WebMCP](https://webmachinelearning.github.io/webmcp/),
6
+ the draft web standard for exposing page actions to agents.
7
+
8
+ You describe each tool once. The SDK registers it with the browser, adds and removes it
9
+ as the user moves between pages, keeps working when the WebMCP draft changes, and does
10
+ nothing at all in browsers that don't support it yet.
11
+
12
+ ```ts
13
+ import { defineTool, registerTools } from "@nekuda/webmcp-sdk";
14
+
15
+ const addToCart = defineTool({
16
+ stableKey: "cart.add",
17
+ name: "add_to_cart",
18
+ description: "Add a product to the shopping cart by SKU.",
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: {
22
+ sku: { type: "string", description: "Product SKU" },
23
+ quantity: { type: "integer", minimum: 1, default: 1 },
24
+ },
25
+ required: ["sku"],
26
+ },
27
+ async execute({ sku, quantity = 1 }: { sku: string; quantity?: number }) {
28
+ const res = await fetch("/cart/add", {
29
+ method: "POST",
30
+ headers: { "content-type": "application/json" },
31
+ body: JSON.stringify({ sku, quantity }),
32
+ });
33
+ if (!res.ok) throw new Error(`Could not add ${sku} to the cart`);
34
+ return await res.json();
35
+ },
36
+ });
37
+
38
+ registerTools([addToCart]);
39
+ ```
40
+
41
+ An agent visiting the page now sees an `add_to_cart` tool, with your description and
42
+ input schema, and can call it.
43
+
44
+ ## Install
45
+
46
+ ```sh
47
+ npm install @nekuda/webmcp-sdk
48
+ # or: pnpm add @nekuda/webmcp-sdk · yarn add @nekuda/webmcp-sdk · bun add @nekuda/webmcp-sdk
49
+ ```
50
+
51
+ The package is a browser ES module with TypeScript types included. It has no runtime
52
+ dependencies. `@opentelemetry/api-logs` is an optional peer, needed only if you turn on
53
+ [OpenTelemetry output](#tool-call-analytics-optional).
54
+
55
+ **No bundler?** Serve `node_modules/@nekuda/webmcp-sdk/dist/index.js` from your site and
56
+ map the package name to it:
57
+
58
+ ```html
59
+ <script type="importmap">
60
+ { "imports": { "@nekuda/webmcp-sdk": "/vendor/webmcp-sdk/index.js" } }
61
+ </script>
62
+ <script type="module" src="/webmcp/entry.js"></script>
63
+ ```
64
+
65
+ ## Defining a tool
66
+
67
+ `defineTool` checks the definition straight away, freezes it and returns it. It does not
68
+ touch the browser, so a module that only defines tools is safe to import anywhere,
69
+ including tests and server-side code. An invalid definition throws a `TypeError` when the
70
+ module loads, not later when an agent calls the tool.
71
+
72
+ | Field | Required | What it is |
73
+ |---|---|---|
74
+ | `stableKey` | yes | Your permanent ID for the tool, in `domain.action` form (`cart.add`, `catalog.search`): lowercase letters, digits and `_`, with at least one dot. Keep it the same when you rename the tool; analytics follow the tool through renames by this key. It is never sent to the browser API. |
75
+ | `name` | no | The name agents see: 1–128 characters of `A–Z a–z 0–9 _ - .`. Defaults to `stableKey`. |
76
+ | `description` | yes | What the tool does, written for the agent. Be specific about when to use it. |
77
+ | `inputSchema` | no | A JSON Schema object describing `execute`'s input. |
78
+ | `title` | no | A human-readable display name. |
79
+ | `annotations` | no | WebMCP hints, for example `{ readOnlyHint: true }` for a tool that only reads. |
80
+ | `pages` | no | Paths the tool is available on. Leave it out to make the tool available everywhere. See [Tools for specific pages](#tools-for-specific-pages). |
81
+ | `version` | no | Your version string for the tool (semver, a build hash, anything). |
82
+ | `intent` | no | `"answer"`, `"act"` or `"transact"`: whether the tool reads, changes something, or moves money. |
83
+ | `source` | no | `"scanner_generated"` or `"merchant_authored"`. |
84
+ | `execute` | yes | Your code. It receives the agent's input and may be `async`. |
85
+
86
+ **Return values.** Return any JSON value or a plain string and the SDK wraps it in the
87
+ result format agents expect. If you already build a WebMCP `{ content: [...] }` result,
88
+ it is passed through unchanged.
89
+
90
+ **Errors.** Throw when the action fails. The error goes back to the agent as it is, so
91
+ write the message for the agent ("No product with SKU 123") and not for your logs.
92
+
93
+ **TypeScript.** Declare the input as a `type`, not an `interface`:
94
+ `defineTool<{ sku: string }>(…)` works, but an `interface` fails the
95
+ `Record<string, unknown>` constraint.
96
+
97
+ ## Registering tools
98
+
99
+ ```ts
100
+ const registration = registerTools([addToCart, searchProducts], options);
101
+
102
+ const results = await registration.ready;
103
+ // [{ stableKey: "cart.add", name: "add_to_cart", state: "registered" }, …]
104
+
105
+ registration.current(); // names registered right now, e.g. ["add_to_cart"]
106
+ registration.unregister(); // remove every tool in this call
107
+ ```
108
+
109
+ Each tool in `ready` ends in one of four states:
110
+
111
+ | State | Meaning |
112
+ |---|---|
113
+ | `registered` | The browser accepted the tool. |
114
+ | `unsupported` | This browser has no WebMCP API. Nothing happened, and nothing needs to be done. |
115
+ | `aborted` | The registration was cancelled before the tool registered. |
116
+ | `failed` | The browser refused the tool. The reason is on `error`. |
117
+
118
+ `ready` never rejects, so you don't need a `try`/`catch` around it. `registerTools` itself
119
+ throws only for a programming error: two tools in one call with the same `name` or the
120
+ same `stableKey`.
121
+
122
+ | Option | What it does |
123
+ |---|---|
124
+ | `signal` | An `AbortSignal`. Aborting it is the same as calling `unregister()`. |
125
+ | `onChange(names)` | Called with the current tool names whenever they change, for example after navigation. |
126
+ | `tracking` | Connects tool calls to your AgentLane account. See [Tool-call analytics](#tool-call-analytics-optional). |
127
+ | `telemetry` | Set to `false` to turn off anonymous usage telemetry for this call. See [Usage telemetry](#usage-telemetry). |
128
+
129
+ ### Where to call it
130
+
131
+ Keep tool definitions in their own modules and call `registerTools` in one place that
132
+ owns the tools' lifetime.
133
+
134
+ **React (and Vite, Remix or any React SPA)**: register in an effect and unregister on
135
+ cleanup.
136
+
137
+ ```tsx
138
+ import { useEffect } from "react";
139
+ import { registerTools } from "@nekuda/webmcp-sdk";
140
+ import { addToCart } from "./tools/cart";
141
+
142
+ export function WebMCPTools() {
143
+ useEffect(() => {
144
+ const registration = registerTools([addToCart]);
145
+ return () => registration.unregister();
146
+ }, []);
147
+ return null;
148
+ }
149
+ ```
150
+
151
+ **Next.js App Router**: put the same component in a file marked `"use client"` and render
152
+ it from `app/layout.tsx`. Never call `registerTools` from a server component.
153
+
154
+ **Plain or server-rendered pages**: load one module with `<script type="module">` from
155
+ your shared layout.
156
+
157
+ ```ts
158
+ import { registerTools } from "@nekuda/webmcp-sdk";
159
+ import { addToCart } from "./tools/cart.js";
160
+
161
+ const registration = registerTools([addToCart]);
162
+ addEventListener("pagehide", () => registration.unregister(), { once: true });
163
+ ```
164
+
165
+ **Tools that need state**: register tools such as "update cart item" or "start checkout"
166
+ only while that state exists (for example, a non-empty cart or a signed-in user). Tie the
167
+ `registerTools` call to that state and unregister when it goes away.
168
+
169
+ ## Tools for specific pages
170
+
171
+ Give a tool a `pages` list and it is only offered on matching pages:
172
+
173
+ ```ts
174
+ defineTool({
175
+ stableKey: "product.add_review",
176
+ description: "Post a review of the product on this page.",
177
+ pages: ["/products/*"],
178
+ execute: postReview,
179
+ });
180
+ ```
181
+
182
+ - `*` matches within one path segment (`/products/*`, `/product.html*`), and `**` matches
183
+ any number of segments (`/docs/**`).
184
+ - A tool with several patterns is available when any of them matches.
185
+ - Query strings and trailing slashes are ignored, and repeated slashes count as one.
186
+ - Hash routers work too: `/#/product/*`.
187
+ - During server-side rendering there is no location to check, so every tool counts as
188
+ eligible.
189
+
190
+ In a single-page app the SDK adds and removes page-scoped tools on every navigation
191
+ (`pushState`, `replaceState`, back/forward and hash changes), so you don't need to call
192
+ anything yourself. `ready` reports only the tools eligible on the first page; use
193
+ `onChange` or `current()` to follow later changes. If your app locks down the History
194
+ API, navigation is still detected through back/forward and hash changes.
195
+
196
+ ## Browser support
197
+
198
+ The SDK works wherever the WebMCP API is present, as `document.modelContext` or
199
+ `navigator.modelContext`, whether a browser provides it natively or an extension does.
200
+ WebMCP is a draft that still changes month to month. The SDK tracks it so your code
201
+ doesn't have to, which is why you should call the SDK and not `modelContext` directly.
202
+
203
+ Where the API is missing, every tool reports `unsupported` and nothing else happens: no
204
+ errors and no changes to your page. You can ship the same code to every browser.
205
+
206
+ ## Tool-call analytics (optional)
207
+
208
+ Connect your site in the [AgentLane dashboard](https://app.agentlane.com) to see which tools agents call, how
209
+ often they succeed and where they fail. Connecting means adding the site's publishable
210
+ key. Your tool definitions don't change.
211
+
212
+ ```ts
213
+ registerTools([addToCart], {
214
+ tracking: { apiKey: "wmk_…" }, // publishable key from the AgentLane dashboard
215
+ });
216
+ ```
217
+
218
+ With a key, the SDK sends an event for each tool call to AgentLane. It is off unless you
219
+ set it. Two other settings are available:
220
+
221
+ - `otel: true` also, or instead, emits each tool-call event as an OpenTelemetry log record
222
+ through your app's global `LoggerProvider`, so you can send it to your own
223
+ observability stack. Your app owns the exporter, and `apiKey` isn't needed for this.
224
+ - `disabled: true` switches tool-call analytics off completely, for example until the
225
+ visitor accepts analytics cookies. While it is set, no identifier is created, nothing
226
+ is stored and nothing is sent.
227
+
228
+ Tool-call analytics keeps an anonymous visitor and session identifier in the browser's
229
+ storage, so ask for consent wherever your analytics consent rules require it.
230
+
231
+ ## Usage telemetry
232
+
233
+ The SDK sends anonymous usage telemetry by default, so we can see which versions are in
234
+ use and catch breakage. It needs no key and **stores nothing in the browser** (no
235
+ cookies, no localStorage, no identifiers).
236
+
237
+ **Sent:** SDK version, whether WebMCP is available, browser family, device type, page
238
+ language and route pattern (`/products/:id`, never the URL); each tool's name, whether it
239
+ registered, and the shape of its schema; and per tool call, the outcome, duration, result
240
+ size and a scrubbed error template.
241
+
242
+ **Never sent:** tool inputs or results, raw error messages, URLs, query strings, referrer
243
+ URLs, page titles or the full user-agent.
244
+
245
+ To count daily visitors, our server stores a one-way hash of site, IP and user-agent under
246
+ a key that rotates every day. It can't be reversed or linked across sites or days. If the
247
+ page sets `tracking.apiKey`, telemetry is attributed to your site.
248
+
249
+ **Turn it off:**
250
+
251
+ ```ts
252
+ globalThis.__WEBMCP_TELEMETRY__ = false; // whole page; set before the SDK loads
253
+ registerTools(tools, { telemetry: false }); // one call
254
+ ```
255
+
256
+ It is also off automatically when the browser sends
257
+ [Global Privacy Control](https://globalprivacycontrol.org/) and during server-side
258
+ rendering. `tracking.disabled` does not affect it.
259
+
260
+ ## Advanced
261
+
262
+ These exports are for code that hosts the SDK on a page it controls, such as a tag
263
+ loader. Most sites don't need them.
264
+
265
+ - `matchPage(patterns, location)` and `currentPageKey()`: the page matcher `pages` uses.
266
+ - `resolveModelContext()`: returns the WebMCP object the SDK would use, or `undefined`.
267
+ - `registerTools(tools, { modelContext })`: register against a specific WebMCP object.
268
+ - `tracking.sessionId` and `createCallTracker(tool, tracking)`: report tool calls under
269
+ your own session ID, including for tools you registered with the browser yourself.
270
+ - The telemetry event types (`TelemetryEvent`, `SdkInitEvent`, `ToolRegistrationEvent`,
271
+ `ToolCallEvent`, …) are exported for anyone consuming the beacons.
272
+
273
+ ## Changelog
274
+
275
+ See `CHANGELOG.md`, which ships in the package.
package/dist/index.d.ts CHANGED
@@ -56,8 +56,10 @@
56
56
  * separate `/v1/telemetry` endpoint, fire-and-forget; the two page-level ones may
57
57
  * share one request as a `batch` envelope, `tool_call` never does. Arrival order
58
58
  * is NOT guaranteed, so consumers join on `sessionId` and never on ordering. A
59
- * page that configures point 5's `apiKey` has it sent here as `x-api-key` too,
60
- * which only adds tenant attribution an unkeyed page sends anonymously to the
59
+ * page that configures point 5's `apiKey` has it sent here too, as the `key`
60
+ * query parameter rather than point 5's header (a custom header would make the
61
+ * beacon non-simple under CORS and cost it a preflight); it only adds tenant
62
+ * attribution — an unkeyed page sends anonymously to the
61
63
  * same path. It composes with point 5: a call can emit to neither channel, either
62
64
  * one, or both. Three page-level levers silence it entirely (no event, no
63
65
  * network), because all three are read at emit time:
package/dist/index.js CHANGED
@@ -181,6 +181,15 @@ function sendToCollect(event, config, scope = globalThis) {
181
181
  }
182
182
  var TELEMETRY_CONTENT_TYPE = "text/plain";
183
183
  var TELEMETRY_KEY_PARAM = "key";
184
+ function withTelemetryKey(base, apiKey) {
185
+ try {
186
+ const url = new URL(base);
187
+ url.searchParams.set(TELEMETRY_KEY_PARAM, apiKey);
188
+ return url.toString();
189
+ } catch {
190
+ return `${base}${base.includes("?") ? "&" : "?"}${TELEMETRY_KEY_PARAM}=${encodeURIComponent(apiKey)}`;
191
+ }
192
+ }
184
193
  function sendTelemetry(event, scope = globalThis, endpoint, apiKey) {
185
194
  try {
186
195
  const json = JSON.stringify(event);
@@ -189,7 +198,7 @@ function sendTelemetry(event, scope = globalThis, endpoint, apiKey) {
189
198
  const base = endpoint || DEFAULT_TELEMETRY_ENDPOINT;
190
199
  const headers = { "content-type": TELEMETRY_CONTENT_TYPE };
191
200
  const authenticated = typeof apiKey === "string" && apiKey.trim().length > 0;
192
- const url = authenticated ? `${base}${base.includes("?") ? "&" : "?"}${TELEMETRY_KEY_PARAM}=${encodeURIComponent(apiKey)}` : base;
201
+ const url = authenticated ? withTelemetryKey(base, apiKey) : base;
193
202
  tryFetch(scope, url, headers, json, authenticated ? (response) => {
194
203
  if (isUnauthorized(response))
195
204
  warnKeyRejected(scope);
@@ -1105,7 +1114,7 @@ function shapeMetrics(inputSchema) {
1105
1114
 
1106
1115
  // src/telemetry.ts
1107
1116
  var SDK_NAME = "@nekuda/webmcp-sdk";
1108
- var SDK_VERSION = "0.7.0-dev.20.1";
1117
+ var SDK_VERSION = "0.7.0-dev.22.1";
1109
1118
  var INSTALL_MODES = ["npm", "cdn_snippet"];
1110
1119
  var SDK_INSTALL_MODE = INSTALL_MODES.find((mode) => mode === (typeof __WEBMCP_INSTALL_MODE__ === "string" ? __WEBMCP_INSTALL_MODE__ : "")) ?? "npm";
1111
1120
  function parseSampleRate(raw) {
@@ -33,8 +33,9 @@ export interface RegisterToolsOptions {
33
33
  * event before its handler runs and a `tool_call_response` event after.
34
34
  * `disabled: true` is this channel's consent gate; it does not narrow the
35
35
  * default-on telemetry channel below, which resolves no identity and touches no
36
- * storage. An `apiKey` set here is also sent as `x-api-key` on that channel's
37
- * beacons, which only attributes them to this tenant.
36
+ * storage. An `apiKey` set here also rides that channel's beacons — on the URL as
37
+ * `?key=`, not as this channel's header — which only attributes them to this
38
+ * tenant.
38
39
  */
39
40
  tracking?: TrackingOptions;
40
41
  /**
@@ -2,8 +2,9 @@
2
2
  * Default-on usage telemetry for `@nekuda/webmcp-sdk`. Independent of the opt-in
3
3
  * `tracking` channel (`src/tracking.ts`): it needs no `apiKey`, so it reports SDK
4
4
  * adoption, browser mix, WebMCP availability, and tool-call reliability from every
5
- * site the SDK runs on. A page that *does* configure one has it sent as
6
- * `x-api-key`, which only adds tenant attribution. Opt out with `telemetry: false`
5
+ * site the SDK runs on. A page that *does* configure one has it sent as the `key`
6
+ * query parameter never a header, which would cost every keyed beacon a CORS
7
+ * preflight — and it only adds tenant attribution. Opt out with `telemetry: false`
7
8
  * on `registerTools`, with `globalThis.__WEBMCP_TELEMETRY__ = false`, or via
8
9
  * Global Privacy Control.
9
10
  *
@@ -388,7 +389,7 @@ export interface TelemetrySinks {
388
389
  * navigation could then lose.
389
390
  *
390
391
  * The queue is keyed on the destination — endpoint plus key — because a batch is one
391
- * request with one `x-api-key`, and two tenants sharing an origin must never travel
392
+ * request under one key, and two tenants sharing an origin must never travel
392
393
  * under each other's key (see {@link batchTelemetrySinks}). The init flush a batch
393
394
  * defers (`deferInitEventForBatch`) uses that batch's sinks, so the common case lands
394
395
  * in one queue. A `pagehide` or a hidden tab flushes everything at once, through the
@@ -5,23 +5,28 @@
5
5
  * v0.
6
6
  *
7
7
  * Transport is `fetch(..., { keepalive: true })` — unload-safe and, unlike
8
- * `sendBeacon`, able to set custom headers. Auth travels as the `x-api-key`
9
- * **header** (never a query param or body field): the deployed API Gateway
10
- * authorizer's identity source is that header, so a request without it is
11
- * rejected 401 before the authorizer runs, and keeping the key out of the URL
12
- * keeps it out of referrer logs and out of the canonical `payload` column. The
13
- * event JSON is the bare POST body; `org_id` is server-injected from the key.
8
+ * `sendBeacon`, able to set custom headers. On **this** channel auth travels as
9
+ * the `x-api-key` **header** (never a query param or body field): the deployed
10
+ * API Gateway authorizer's identity source is that header, so a request without
11
+ * it is rejected 401 before the authorizer runs, and keeping the secret key out
12
+ * of the URL keeps it out of referrer logs and out of the canonical `payload`
13
+ * column. The event JSON is the bare POST body; `org_id` is server-injected from
14
+ * the key.
14
15
  *
15
16
  * The scope is injectable for tests (spec.ts pattern). Every browser global is
16
17
  * guarded and the whole function is wrapped so telemetry never throws into the
17
18
  * caller.
18
19
  *
19
20
  * `sendTelemetry` is the sibling sender for the default-on usage-telemetry
20
- * channel (`src/telemetry.ts`): same keepalive/fire-and-forget discipline and the
21
- * same `x-api-key` header *when the page configured a key*, on a distinct
22
- * `/v1/telemetry` path that accepts the request either way. That channel runs on
23
- * pages that never key the authenticated one, so a keyless send is the normal
24
- * case, not a failure the backend attributes those by CORS `Origin` instead.
21
+ * channel (`src/telemetry.ts`): same keepalive/fire-and-forget discipline, on a
22
+ * distinct `/v1/telemetry` path that accepts the request with or without a key.
23
+ * It does **not** share the header above it puts the *publishable* key on the
24
+ * URL as `?key=`, because a custom header would cost that channel a CORS
25
+ * preflight it exists to avoid; see {@link sendTelemetry} for the whole reason
26
+ * and for why the exposure that argues against a URL here does not argue against
27
+ * it there. That channel runs on pages that never key the authenticated one, so a
28
+ * keyless send is the normal case, not a failure — the backend attributes those
29
+ * by CORS `Origin` instead.
25
30
  *
26
31
  * That path has exactly one observable failure signal, and it is a `console.error`,
27
32
  * never a throw or a retry: a *keyed* beacon whose key the backend cannot resolve
@@ -40,7 +45,7 @@ export declare const DEFAULT_COLLECT_ENDPOINT: string;
40
45
  * Default ingest endpoint for the default-on usage-telemetry channel — a
41
46
  * **separate path** from {@link DEFAULT_COLLECT_ENDPOINT} so the backend can route
42
47
  * the two channels independently: one authorizer-protected, this one unauthenticated
43
- * by default and accepting the same request with an optional `x-api-key` for tenant
48
+ * by default and accepting the same request with an optional `?key=` for tenant
44
49
  * attribution. Same per-flavor base host (see {@link INGEST_BASE}).
45
50
  */
46
51
  export declare const DEFAULT_TELEMETRY_ENDPOINT: string;
@@ -62,7 +67,7 @@ export declare const TELEMETRY_CONTENT_TYPE = "text/plain";
62
67
  export declare const TELEMETRY_KEY_PARAM = "key";
63
68
  /**
64
69
  * POST a usage-telemetry event to the telemetry endpoint via `fetch(keepalive)`.
65
- * The path is the same whether or not `apiKey` is given: the header only *adds*
70
+ * The path is the same whether or not `apiKey` is given: the key only *adds*
66
71
  * tenant attribution to an event the backend would accept anyway, so a page that
67
72
  * never configures the authenticated channel keeps sending, and the backend falls
68
73
  * back to the CORS `Origin` header. Never throws: any failure is swallowed and a
@@ -71,8 +76,8 @@ export declare const TELEMETRY_KEY_PARAM = "key";
71
76
  * `apiKey` is `unknown` because it originates in caller-supplied options that no
72
77
  * compiler checked; anything that is not a non-blank string sends unauthenticated
73
78
  * rather than putting `"undefined"` (or a hostile object's `toString`) on the
74
- * wire. Note that adding the header makes the request non-simple under CORS, so
75
- * an authenticated beacon costs a preflight the anonymous one does not.
79
+ * wire. A keyed beacon differs from an anonymous one only in its URL: the request
80
+ * stays CORS-simple either way, so neither costs a preflight.
76
81
  *
77
82
  * The body is sent as `text/plain`, not `application/json`, and that is a cost
78
83
  * decision, not a formatting one: `text/plain` is a CORS-safelisted content type,
@@ -81,7 +86,7 @@ export declare const TELEMETRY_KEY_PARAM = "key";
81
86
  * per event at the edge; at 2026-09 volume that preflight was roughly half of the
82
87
  * seven million requests a day the collect API billed. The edge maps `text/plain`
83
88
  * to the same template as JSON (ingestion-edge/telemetry.tf), so nothing changes
84
- * on the wire but the header.
89
+ * on the wire but the content type.
85
90
  *
86
91
  * The publishable key travels as the `key` query parameter, not as `x-api-key`. A
87
92
  * custom header is what makes a request non-simple, so a keyed beacon with the header
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@nekuda/webmcp-sdk",
3
- "version": "0.7.0-dev.20.1",
3
+ "version": "0.7.0-dev.22.1",
4
4
  "type": "module",
5
- "description": "Phase-1 WebMCP SDK: a thin wrapper over document.modelContext that plugin-generated code targets — defineTool + register/unregister lifecycle. This package pins the plugin↔SDK seam; anonymous tool-call tracking (backend transport via apiKey, OTEL LogRecords via otel) is opt-in through registerTools and default-silent, while anonymous usage telemetry is a separate unauthenticated channel that is on by default (opt out with telemetry: false).",
5
+ "description": "Let AI agents use your website: define tools once and register them through WebMCP, with page-scoped tools, SPA navigation and unsupported browsers handled for you.",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "exports": {