@flusterduck/mcp-server 0.5.7 → 0.7.2
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/LICENSE.md +23 -0
- package/README.md +1 -0
- package/dist/{chunk-5PJCE3ED.js → chunk-7KMA7AVO.js} +154 -30
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +32 -0
- package/dist/index.js +1 -1
- package/package.json +16 -25
package/LICENSE.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
PROPRIETARY LICENSE FOR FLUSTERDUCK
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Creayo
|
|
4
|
+
|
|
5
|
+
1. DEFINITIONS
|
|
6
|
+
"Software" refers to the Flusterduck source code, compiled binaries, SDKs, libraries, and documentation.
|
|
7
|
+
"Package" refers to the officially distributed compiled artifacts (e.g., via NPM).
|
|
8
|
+
|
|
9
|
+
2. USAGE GRANT
|
|
10
|
+
Subject to the terms of this License, Creayo hereby grants you a non-exclusive, worldwide, royalty-free license to use, incorporate, and bundle the compiled Packages into your own applications for any purpose, including commercial use.
|
|
11
|
+
|
|
12
|
+
3. SOURCE CODE RESTRICTIONS
|
|
13
|
+
You are strictly prohibited from:
|
|
14
|
+
a) Copying, modifying, or distributing the Software's original source code.
|
|
15
|
+
b) Reverse engineering, decompiling, or disassembling the Software or any of its components.
|
|
16
|
+
c) Creating derivative works based on the Software's source code.
|
|
17
|
+
d) Removing or altering any copyright notices or branding from the Software.
|
|
18
|
+
|
|
19
|
+
4. OWNERSHIP
|
|
20
|
+
Creayo retains all rights, title, and interest in and to the Software, including all intellectual property rights.
|
|
21
|
+
|
|
22
|
+
5. DISCLAIMER OF WARRANTY
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -75,6 +75,7 @@ Add to your `claude_desktop_config.json`:
|
|
|
75
75
|
| `get_heuristics` | Full friction heuristic catalog |
|
|
76
76
|
| `diagnose_journey_friction` | High-friction path edges from recent sessions |
|
|
77
77
|
| `query_raw_rows` | Raw rows from allowlisted tables |
|
|
78
|
+
| `explore` | Deterministic typed query engine over session data: AND-ed filters, list sessions or measure a metric |
|
|
78
79
|
| `download_events_csv` | CSV export of raw events |
|
|
79
80
|
| `get_audit_log` | Organization audit log (requires org ID) |
|
|
80
81
|
| `get_degradation` | Active backend degradation events (requires org ID) |
|
|
@@ -3,7 +3,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
5
5
|
// src/api-client.ts
|
|
6
|
-
var MACHINE_KEY_PATTERN = /^fd_(sec|mcp)_[A-Za-z0-9_-]{
|
|
6
|
+
var MACHINE_KEY_PATTERN = /^fd_(sec|mcp)_[A-Za-z0-9_-]{24,128}$/;
|
|
7
7
|
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
8
8
|
function assertMachineKey(value) {
|
|
9
9
|
const key = value.trim();
|
|
@@ -71,6 +71,10 @@ var FlusterduckAPI = class {
|
|
|
71
71
|
const safeDays = Math.max(1, Math.min(Math.trunc(Number(days) || 7), 90));
|
|
72
72
|
return this.request("query/trends", { days: safeDays, page });
|
|
73
73
|
}
|
|
74
|
+
getConversionInsights(days = 7) {
|
|
75
|
+
const safeDays = Math.max(1, Math.min(Math.trunc(Number(days) || 7), 90));
|
|
76
|
+
return this.request("query/insights", { days: safeDays });
|
|
77
|
+
}
|
|
74
78
|
getDeployImpact() {
|
|
75
79
|
return this.request("query/deploys");
|
|
76
80
|
}
|
|
@@ -107,9 +111,36 @@ var FlusterduckAPI = class {
|
|
|
107
111
|
if (!this.config.orgId) throw new Error("orgId is required for webhook delivery queries.");
|
|
108
112
|
return this.request("query/webhook-deliveries", { org_id: this.config.orgId, limit }, false);
|
|
109
113
|
}
|
|
114
|
+
// Explorer: the query body can carry up to 12 filters plus an output
|
|
115
|
+
// spec, which is awkward to encode as a `?q=` querystring, so this reads
|
|
116
|
+
// over POST instead of GET. Still read-only and site-scoped like every
|
|
117
|
+
// other query/* call -- see postQuery().
|
|
118
|
+
explore(query) {
|
|
119
|
+
return this.postQuery("query/explore", query);
|
|
120
|
+
}
|
|
110
121
|
getIssueDetail(issueId) {
|
|
111
122
|
return this.request(`query/issues/${assertUuid(issueId, "issue_id")}`, {});
|
|
112
123
|
}
|
|
124
|
+
getRollup(days) {
|
|
125
|
+
if (!this.config.orgId) throw new Error("orgId is required for rollup queries.");
|
|
126
|
+
const safeDays = days ? Math.max(1, Math.min(Math.trunc(Number(days) || 14), 90)) : void 0;
|
|
127
|
+
return this.request("query/rollup", { org_id: this.config.orgId, days: safeDays }, false);
|
|
128
|
+
}
|
|
129
|
+
getElementHeatmap(page, selector) {
|
|
130
|
+
return this.request("query/element-heatmap", { page, selector });
|
|
131
|
+
}
|
|
132
|
+
getPageHeatmap(page) {
|
|
133
|
+
return this.request("query/page-heatmap", { page });
|
|
134
|
+
}
|
|
135
|
+
getGuideStats() {
|
|
136
|
+
return this.request("query/guide-stats");
|
|
137
|
+
}
|
|
138
|
+
getAlertDetail(alertId) {
|
|
139
|
+
return this.request(`query/alerts/${assertUuid(alertId, "alert_id")}`, {});
|
|
140
|
+
}
|
|
141
|
+
getDeployDetail(deployId) {
|
|
142
|
+
return this.request(`query/deploys/${assertUuid(deployId, "deploy_id")}`, {});
|
|
143
|
+
}
|
|
113
144
|
getAlertRules() {
|
|
114
145
|
return this.request("manage/alert-rules", {});
|
|
115
146
|
}
|
|
@@ -166,6 +197,37 @@ var FlusterduckAPI = class {
|
|
|
166
197
|
}
|
|
167
198
|
return envelope.data;
|
|
168
199
|
}
|
|
200
|
+
// Read-over-POST: a site-scoped query/* read whose body doesn't fit a
|
|
201
|
+
// URL querystring (e.g. Explorer's filters + output spec). Distinct from
|
|
202
|
+
// mutate(), which targets manage/* write routes and requires manage:write
|
|
203
|
+
// scope -- this hits query/* and only ever reads.
|
|
204
|
+
async postQuery(route, body) {
|
|
205
|
+
const base = assertSafeApiBase(this.config.baseUrl);
|
|
206
|
+
const url = new URL(route.replace(/^\/+/, ""), base);
|
|
207
|
+
url.searchParams.set("site_id", assertUuid(this.config.siteId, "site_id"));
|
|
208
|
+
const response = await fetch(url, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: {
|
|
211
|
+
authorization: `Bearer ${this.config.apiKey}`,
|
|
212
|
+
"content-type": "application/json",
|
|
213
|
+
accept: "application/json"
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify(body)
|
|
216
|
+
});
|
|
217
|
+
let envelope;
|
|
218
|
+
try {
|
|
219
|
+
envelope = await response.json();
|
|
220
|
+
} catch {
|
|
221
|
+
throw new Error(`Flusterduck API returned non-JSON response (${response.status}).`);
|
|
222
|
+
}
|
|
223
|
+
if (!response.ok || envelope.error) {
|
|
224
|
+
const status = envelope.error?.status ?? response.status;
|
|
225
|
+
const code = envelope.error?.code ?? "unknown";
|
|
226
|
+
const message = envelope.error?.message ?? "Flusterduck API request failed.";
|
|
227
|
+
throw new Error(`Flusterduck API error (${status}: ${code}): ${message}`);
|
|
228
|
+
}
|
|
229
|
+
return envelope.data;
|
|
230
|
+
}
|
|
169
231
|
async request(route, params = {}, includeSiteId = true) {
|
|
170
232
|
const url = includeSiteId ? buildQueryUrl(this.config, route, params) : buildOrgUrl(this.config, route, params);
|
|
171
233
|
const response = await fetch(url, {
|
|
@@ -276,13 +338,14 @@ The SDK's privacy constraints are enforced at the code level, not just by policy
|
|
|
276
338
|
|
|
277
339
|
Paid product. No free tier. No open-source version.
|
|
278
340
|
|
|
279
|
-
| Plan | Monthly | Sessions |
|
|
280
|
-
|
|
281
|
-
| Grow | $49 | Up to 50,000 |
|
|
282
|
-
| Scale | $149 | Up to 250,000 |
|
|
283
|
-
|
|
|
341
|
+
| Plan | Monthly | Sessions | Sites | Key features |
|
|
342
|
+
|---|---|---|---|---|
|
|
343
|
+
| Grow | $49 | Up to 50,000 | 1 | Core friction detection, issues, alerts, MCP access |
|
|
344
|
+
| Scale | $149 | Up to 250,000 | 3 | Everything in Grow, plus team members, Slack integration |
|
|
345
|
+
| Pro | $299 | Up to 1,000,000 | 10 | Everything in Scale, plus unlimited members, revenue estimates, priority support |
|
|
346
|
+
| Enterprise | Custom | Custom | Unlimited | Custom volume, SSO, dedicated support, SLA |
|
|
284
347
|
|
|
285
|
-
All plans include a 7-day free trial. No credit card required.
|
|
348
|
+
Session limits are pooled across all sites in the org, not per-site. All plans include a 7-day free trial. No credit card required.
|
|
286
349
|
`
|
|
287
350
|
},
|
|
288
351
|
{
|
|
@@ -290,14 +353,14 @@ All plans include a 7-day free trial. No credit card required.
|
|
|
290
353
|
"title": "Quickstart",
|
|
291
354
|
"description": "The CLI does everything. Run it, paste your publishable key, deploy.",
|
|
292
355
|
"group": "Getting started",
|
|
293
|
-
"content": "# Quickstart\n\nThe CLI does everything. Run it, paste your `fd_pub_` key, deploy.\n\n```bash\nnpx flusterduck-cli init\n```\n\nIt detects your framework (Next.js, React, Vue, SvelteKit, Nuxt), installs the right packages, and injects the init call into the correct entry file. Pass `--key fd_pub_xxxx` to skip the prompt entirely.\n\nThat's the whole quickstart for most apps. The rest of this page is for manual setup and verification.\n\n---\n\n## Manual setup\n\n```bash\npnpm add flusterduck\n```\n\nCall `init()` once at app startup, before any user interaction:\n\n```ts\nimport { init } from 'flusterduck'\n\ninit({ key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY! })\n```\n\nThe SDK starts detecting behavioral signals from that point. No other configuration required to start collecting.\n\nIf you're using a framework, there's a wrapper that handles lifecycle automatically. See [Framework guides](./frameworks) for Next.js, React, Vue, SvelteKit, and Nuxt.\n\n## Check the connection\n\nOpen your app and click around for 30 seconds. Hit a form, click some buttons. Signals appear in the dashboard under **Live Events** within a few seconds of being emitted.\n\nTo confirm the connection without deploying, fire a test signal from the browser console:\n\n```ts\nimport { signal } from 'flusterduck'\nsignal('dead_click', { selector: 'button[type=\"submit\"]' })\n```\n\nIf it shows up in Live Events, you're live.\n\nOne thing that trips people up: the SDK only runs client-side. If you're server-rendering and checking for signals immediately, open the page in a browser first.\n\n## Your key\n\n`fd_pub_` keys are safe in client-side code. They can only emit signals -- they can't read your data or change anything.\n\n```bash\n# Next.js\nNEXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# Vite / Vue / SvelteKit\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# Nuxt\nNUXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\n`fd_sec_` and `fd_mcp_` keys are different. Both are server-side only. Secret keys can read your site data through the query API; MCP keys can read through the MCP bridge and may be granted additional tool scopes. Keep both out of the browser.\n\n## What you'll see first\n\nWithin a few sessions of real traffic, you'll have confusion scores on your tracked pages. The first issues typically surface within 24-48 hours, once the scoring engine has enough signal clusters.\n\nThe signals you'll see most in early data: `rage_click`, `dead_click`, and `form_abandonment`. They're the clearest indicators of friction and tend to accumulate quickly on any app with real users.\n\n## Where to go from here\n\n- [Signals](./signals) -- what each of the 33 signal types means and when to act on it\n- [SDK reference](./sdk) -- every `init()` option and method, with guidance on what actually matters\n- [Alerts](./alerts) -- get notified when a deploy breaks something before your users tell you\n- [MCP setup](./mcp) -- ask your AI assistant about friction data directly instead of checking a dashboard\n"
|
|
356
|
+
"content": "# Quickstart\n\nThe CLI does everything. Run it, paste your `fd_pub_` key, deploy.\n\n```bash\nnpx flusterduck-cli init\n```\n\nIt detects your framework (Next.js, React, Vue, SvelteKit, Nuxt), installs the right packages, and injects the init call into the correct entry file. Pass `--key fd_pub_xxxx` to skip the prompt entirely.\n\nThat's the whole quickstart for most apps. The rest of this page is for manual setup and verification.\n\n---\n\n## Manual setup\n\n### Script tag\n\nNo build step: paste one tag before the closing `</body>`.\n\n```html\n<script src=\"https://flusterduck.com/d.js\" data-key=\"fd_pub_xxxxxxxxxxxx\" data-dnt=\"false\" async></script>\n```\n\nThe tag is `async` so a slow or unreachable CDN can never block the host page's parse, paint, or `DOMContentLoaded` -- and the runtime itself is fire-and-forget (`sendBeacon`/`fetch` with `keepalive`) with `init()` wrapped in a try/catch, so it can never throw into your page either. If you need zero third-party runtime dependency, or run under a CSP that forbids third-party script hosts, use the npm path below instead.\n\n### npm\n\n```bash\npnpm add flusterduck\n```\n\nCall `init()` once at app startup, before any user interaction:\n\n```ts\nimport { init } from 'flusterduck'\n\ninit({ key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY! })\n```\n\nThe SDK starts detecting behavioral signals from that point. No other configuration required to start collecting.\n\nIf you're using a framework, there's a wrapper that handles lifecycle automatically. See [Framework guides](./frameworks) for Next.js, React, Vue, SvelteKit, and Nuxt.\n\n## Check the connection\n\nOpen your app and click around for 30 seconds. Hit a form, click some buttons. Signals appear in the dashboard under **Live Events** within a few seconds of being emitted.\n\nTo confirm the connection without deploying, fire a test signal from the browser console:\n\n```ts\nimport { signal } from 'flusterduck'\nsignal('dead_click', { selector: 'button[type=\"submit\"]' })\n```\n\nIf it shows up in Live Events, you're live.\n\nOne thing that trips people up: the SDK only runs client-side. If you're server-rendering and checking for signals immediately, open the page in a browser first.\n\n## Your key\n\n`fd_pub_` keys are safe in client-side code. They can only emit signals -- they can't read your data or change anything.\n\n```bash\n# Next.js\nNEXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# Vite / Vue / SvelteKit\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# Nuxt\nNUXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\n`fd_sec_` and `fd_mcp_` keys are different. Both are server-side only. Secret keys can read your site data through the query API; MCP keys can read through the MCP bridge and may be granted additional tool scopes. Keep both out of the browser.\n\n## What you'll see first\n\nWithin a few sessions of real traffic, you'll have confusion scores on your tracked pages. The first issues typically surface within 24-48 hours, once the scoring engine has enough signal clusters.\n\nThe signals you'll see most in early data: `rage_click`, `dead_click`, and `form_abandonment`. They're the clearest indicators of friction and tend to accumulate quickly on any app with real users.\n\n## Where to go from here\n\n- [Signals](./signals) -- what each of the 33 signal types means and when to act on it\n- [SDK reference](./sdk) -- every `init()` option and method, with guidance on what actually matters\n- [Alerts](./alerts) -- get notified when a deploy breaks something before your users tell you\n- [MCP setup](./mcp) -- ask your AI assistant about friction data directly instead of checking a dashboard\n"
|
|
294
357
|
},
|
|
295
358
|
{
|
|
296
359
|
"slug": "install",
|
|
297
360
|
"title": "Installation",
|
|
298
361
|
"description": "Install the core SDK plus any framework wrapper that matches your stack.",
|
|
299
362
|
"group": "Getting started",
|
|
300
|
-
"content":
|
|
363
|
+
"content": '# Installation\n\n## Script tag\n\nThe fastest way to install: paste one tag before the closing `</body>`, no build step required.\n\n```html\n<script src="https://flusterduck.com/d.js" data-key="fd_pub_xxxxxxxxxxxx" data-dnt="false" async></script>\n```\n\nReplace `fd_pub_xxxxxxxxxxxx` with your publishable key from Settings > API Keys. Keep `data-dnt="false"` -- the SDK captures no PII (element labels are redacted before they leave the browser), no form values, and the default Do Not Track behavior silently drops 30-50% of visitors (Firefox, Brave, privacy extensions) for no privacy benefit here.\n\nThe bootstrap reads its config from `document.currentScript`, and falls back to `document.querySelector(\'script[data-key^="fd_pub_"]\')` if that\'s unset -- so injecting the tag dynamically (`document.createElement(\'script\')`, tag managers, etc.) works too, as long as `async` and `data-key` are set.\n\n### Loading & performance\n\nThe tag is `async`, not `defer`: a slow or unreachable CDN can never block the host page\'s parse, paint, or `DOMContentLoaded`. Once it loads, the SDK is fire-and-forget -- events are sent with `navigator.sendBeacon` or a `fetch` with `keepalive: true`, and `init()` runs inside a try/catch so an SDK failure can never throw into your page.\n\nWant zero third-party runtime dependency, or run under a strict CSP that forbids third-party script hosts? Install via npm instead (below) and self-host the bundle.\n\n## Packages\n\nInstall the core SDK plus any framework wrapper that matches your stack.\n\n### Core SDK\n\n```bash\npnpm add flusterduck\n```\n\nRequired for every project. The framework wrappers depend on it.\n\n### Framework wrappers\n\n```bash\n# Next.js (App Router or Pages Router)\npnpm add flusterduck @flusterduck/next\n\n# React (Vite, CRA, or any React app)\npnpm add flusterduck @flusterduck/react\n\n# Vue 3\npnpm add flusterduck @flusterduck/vue\n\n# SvelteKit\npnpm add flusterduck @flusterduck/svelte\n\n# Nuxt 3\npnpm add flusterduck @flusterduck/nuxt\n```\n\nNot using a framework wrapper? Initialize the SDK directly with `init()`. It works in any browser environment.\n\n## Publishable key\n\nYour publishable key starts with `fd_pub_`. Find it in the dashboard under Settings > API Keys.\n\nSet it as an environment variable:\n\n```bash\n# Next.js\nNEXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# Vite / Vue / SvelteKit\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# Nuxt\nNUXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nThe `fd_pub_` key is safe in client-side code. It can only send signals -- it can\'t read data or modify your account.\n\n## What each package does\n\n| Package | What it does |\n|---|---|\n| `flusterduck` | Core browser SDK. On-device signal detection/classification and event buffering. |\n| `@flusterduck/next` | `FlusterduckScript` component + `useFlusterduck` hook for Next.js App Router and Pages Router. |\n| `@flusterduck/react` | `FlusterduckProvider` component + `useFlusterduck` hook. |\n| `@flusterduck/vue` | Vue 3 plugin + `useFlusterduck` composable. |\n| `@flusterduck/svelte` | SvelteKit module + Svelte store. |\n| `@flusterduck/nuxt` | Nuxt 3 module. Auto-initializes on the client. |\n\n## Using the CLI instead\n\nThe CLI handles installation and code injection in one command:\n\n```bash\nnpx flusterduck-cli init\n```\n\nSee [CLI reference](./cli) for options.\n\n## Requirements\n\n- Browser environment (the SDK doesn\'t run server-side)\n- HTTPS in production\n- A site and publishable key from the dashboard\n'
|
|
301
364
|
},
|
|
302
365
|
{
|
|
303
366
|
"slug": "start",
|
|
@@ -325,7 +388,7 @@ All plans include a 7-day free trial. No credit card required.
|
|
|
325
388
|
"title": "SDK reference",
|
|
326
389
|
"description": "Configuration options and the full method surface of the browser SDK.",
|
|
327
390
|
"group": "SDK & frameworks",
|
|
328
|
-
"content": "# SDK Reference\n\nOne required config option: `key`. The SDK handles everything else automatically.\n\n## Installation\n\n```bash\npnpm add flusterduck\n```\n\nFramework wrappers install alongside the core SDK:\n\n```bash\npnpm add flusterduck @flusterduck/next # Next.js\npnpm add flusterduck @flusterduck/react # React\npnpm add flusterduck @flusterduck/vue # Vue\npnpm add flusterduck @flusterduck/svelte # SvelteKit\npnpm add flusterduck @flusterduck/nuxt # Nuxt\n```\n\n## What you get automatically\n\nOnce `init()` runs, the SDK starts detecting all 33 behavioral signal types without any additional code. You don't call `signal()` for rage clicks, dead clicks, form abandonment, scroll confusion, or mobile tap misses -- those just work.\n\nSignal classification runs locally in the browser. The SDK sends classified signals and safe metadata to the ingest API; scoring, alerting, and issue correlation happen after ingest.\n\nYou only call `signal()` manually for signals tied to application logic the SDK can't observe (a specific element that isn't interactive by default, a custom form flow, etc.), or to add metadata to auto-detected signals.\n\n## init()\n\nCall once at app startup. Calling it multiple times after initialization is a no-op.\n\n```ts\nimport { init } from 'flusterduck'\n\ninit({ key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY! })\n```\n\n### Config options\n\n| Option | Type | Default | Notes |\n|---|---|---|---|\n| `key` | `string` | **required** | Your `fd_pub_` publishable key. Passing a `fd_sec_` key here logs an error and refuses to initialize. |\n| `environment` | `string` | `\"production\"` | Attached to every signal. Use `\"staging\"` or `\"development\"` to keep non-prod data out of your production scores. |\n| `sampleRate` | `number` | `1` | Fraction of sessions to track. Most teams don't need this until they're at 50k+ sessions/month. At that point, `0.5` cuts your ingestion in half with negligible impact on signal quality. |\n| `debug` | `boolean` | `false` | Logs every signal to the console as it's emitted. Don't ship with this on -- it's noisy. |\n| `segment` | `Record<string, string>` | `undefined` | Static tags attached to every signal in the session, such as release version or experiment cohort. |\n\nMost teams ship with only `key`, `environment`, and a small `segment` object. That's enough.\n\n### Example\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n environment: process.env.NODE_ENV === 'production' ? 'production' : 'staging',\n segment: {\n app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown',\n },\n})\n```\n\n## signal()\n\nEmit a behavioral signal manually. Use this when you need to tie a signal to application state the SDK can't observe automatically, or to attach metadata.\n\n```ts\nimport { signal } from 'flusterduck'\n\nsignal(type, metadata?)\n```\n\n```ts\n// Attach metadata to a signal the SDK might also auto-detect\nsignal('dead_click', {\n selector: '[data-plan=\"enterprise\"]',\n label: 'Enterprise CTA',\n page_section: 'pricing-table',\n})\n\n// Signal for a custom multi-step flow\nsignal('form_abandonment', {\n form: 'onboarding',\n step: 3,\n last_field: 'company_size',\n})\n```\n\nNever put PII, form values, or anything user-typed into metadata. Safe fields: element selectors, labels, page section names, plan IDs, product IDs.\n\n## track()\n\nRecord business events. The scoring engine uses these to estimate revenue impact from friction.\n\n```ts\nimport { track } from 'flusterduck'\n\ntrack(event, properties?)\n```\n\n```ts\n// User expressed intent to purchase\ntrack('plan_intent', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\n// Purchase completed\ntrack('subscription_started', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\n// Cart or checkout abandoned\ntrack('checkout_abandoned', {\n plan_id: 'grow',\n amount_cents: 3900,\n})\n```\n\nSafe properties: `plan_id`, `amount_cents`, `billing`, `currency`, `quantity`, `product_id`. Never pass names, emails, addresses, or any text the user typed.\n\n## identify()\n\nTag the current session with user or account properties. Values must be safe strings. Use opaque IDs only -- a database primary key or UUID is fine, but not an email or name.\n\n```ts\nimport { identify } from 'flusterduck'\n\n// After login\nidentify({\n user_id: 'usr_8f3a2c91',\n plan: 'scale',\n account_age_days: '42',\n})\n```\n\nDo not pass `user@example.com` or `\"Alice Johnson\"` as any property. Flusterduck doesn't know which of your values are personally identifying, so that's on you.\n\n## setConsent()\n\nUpdate consent state after initialization. Called when a user accepts or rejects your cookie/tracking prompt.\n\n```ts\nimport { setConsent } from 'flusterduck'\n\nsetConsent(true) // start tracking\nsetConsent(false) // pause tracking, flush buffer\n```\n\nFor strict pre-consent gating, wait to call `init()` until the user accepts, or use a framework wrapper with `enabled: false`. Calling `setConsent(false)` on an active session flushes the buffer and stops collection immediately.\n\n## optOut()\n\nOpt the current user out for the session. Semantically clearer than `setConsent(false)` when the user has explicitly requested no tracking, versus consent not yet collected.\n\n```ts\nimport { optOut } from 'flusterduck'\n\noptOut()\n```\n\n## destroy()\n\nShut down the SDK, remove all listeners, clear the session buffer. Use this if you need to completely tear down Flusterduck from a running app instance -- for example, in a test environment or an embedded widget that gets unmounted.\n\n```ts\nimport { destroy } from 'flusterduck'\n\ndestroy()\n```\n\nCalling `init()` after `destroy()` reinitializes a fresh session.\n\n## TypeScript\n\nThe package ships full type definitions. If you want compile-time safety on signal types:\n\n```ts\nimport { signal } from 'flusterduck'\nimport type { SignalType } from 'flusterduck'\n\nconst type: SignalType = 'rage_click' // autocomplete across all 33 types\nsignal(type, { selector: '#submit-btn' })\n```\n\n## Common mistakes\n\n**Passing a secret key.** The SDK checks the key prefix on `init()` and will refuse to initialize with `fd_sec_` or `fd_mcp_` keys. It logs a console error pointing to this page.\n\n**Calling `init()` in a server component.** The SDK is browser-only. Calling `init()` in a Next.js Server Component, an edge function, or any non-browser context will throw. Gate it with `typeof window !== 'undefined'` if needed, or use the `@flusterduck/next` wrapper which handles this automatically.\n\n**Calling `identify()` before initialization.** `identify()` is a no-op until `init()` has completed. Call it as early as possible after login and after the SDK is initialized.\n"
|
|
391
|
+
"content": "# SDK Reference\n\nOne required config option: `key`. The SDK handles everything else automatically.\n\n## Installation\n\n```bash\npnpm add flusterduck\n```\n\nFramework wrappers install alongside the core SDK:\n\n```bash\npnpm add flusterduck @flusterduck/next # Next.js\npnpm add flusterduck @flusterduck/react # React\npnpm add flusterduck @flusterduck/vue # Vue\npnpm add flusterduck @flusterduck/svelte # SvelteKit\npnpm add flusterduck @flusterduck/nuxt # Nuxt\n```\n\n### Or the script tag\n\nNo package install needed:\n\n```html\n<script src=\"https://flusterduck.com/d.js\" data-key=\"fd_pub_xxxxxxxxxxxx\" data-dnt=\"false\" async></script>\n```\n\nAlways use `async`, never `defer` -- it's the only way a slow or unreachable CDN can never delay the host page's `DOMContentLoaded`. The bootstrap reads its config from `document.currentScript`, falling back to `document.querySelector('script[data-key^=\"fd_pub_\"]')` when the tag is injected dynamically (tag managers, `document.createElement('script')`), so `<script async>` injection works either way.\n\n## What you get automatically\n\nOnce `init()` runs, the SDK starts detecting all 33 behavioral signal types without any additional code. You don't call `signal()` for rage clicks, dead clicks, form abandonment, scroll confusion, or mobile tap misses -- those just work.\n\nSignal classification runs locally in the browser. The SDK sends classified signals and safe metadata to the ingest API; scoring, alerting, and issue correlation happen after ingest.\n\nYou only call `signal()` manually for signals tied to application logic the SDK can't observe (a specific element that isn't interactive by default, a custom form flow, etc.), or to add metadata to auto-detected signals.\n\n## init()\n\nCall once at app startup. Calling it multiple times after initialization is a no-op.\n\n```ts\nimport { init } from 'flusterduck'\n\ninit({ key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY! })\n```\n\n### Config options\n\n| Option | Type | Default | Notes |\n|---|---|---|---|\n| `key` | `string` | **required** | Your `fd_pub_` publishable key. Passing a `fd_sec_` key here logs an error and refuses to initialize. |\n| `environment` | `string` | `\"production\"` | Attached to every signal. Use `\"staging\"` or `\"development\"` to keep non-prod data out of your production scores. |\n| `sampleRate` | `number` | `1` | Fraction of sessions to track. Most teams don't need this until they're at 50k+ sessions/month. At that point, `0.5` cuts your ingestion in half with negligible impact on signal quality. |\n| `debug` | `boolean` | `false` | Logs every signal to the console as it's emitted. Don't ship with this on -- it's noisy. |\n| `segment` | `Record<string, string>` | `undefined` | Static tags attached to every signal in the session, such as release version or experiment cohort. |\n\nMost teams ship with only `key`, `environment`, and a small `segment` object. That's enough.\n\n### Example\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n environment: process.env.NODE_ENV === 'production' ? 'production' : 'staging',\n segment: {\n app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown',\n },\n})\n```\n\n## signal()\n\nEmit a behavioral signal manually. Use this when you need to tie a signal to application state the SDK can't observe automatically, or to attach metadata.\n\n```ts\nimport { signal } from 'flusterduck'\n\nsignal(type, metadata?)\n```\n\n```ts\n// Attach metadata to a signal the SDK might also auto-detect\nsignal('dead_click', {\n selector: '[data-plan=\"enterprise\"]',\n label: 'Enterprise CTA',\n page_section: 'pricing-table',\n})\n\n// Signal for a custom multi-step flow\nsignal('form_abandonment', {\n form: 'onboarding',\n step: 3,\n last_field: 'company_size',\n})\n```\n\nNever put PII, form values, or anything user-typed into metadata. Safe fields: element selectors, labels, page section names, plan IDs, product IDs.\n\n## track()\n\nRecord business events. The scoring engine uses these to estimate revenue impact from friction.\n\n```ts\nimport { track } from 'flusterduck'\n\ntrack(event, properties?)\n```\n\n```ts\n// User expressed intent to purchase\ntrack('plan_intent', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\n// Purchase completed\ntrack('subscription_started', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\n// Cart or checkout abandoned\ntrack('checkout_abandoned', {\n plan_id: 'grow',\n amount_cents: 3900,\n})\n```\n\nSafe properties: `plan_id`, `amount_cents`, `billing`, `currency`, `quantity`, `product_id`. Never pass names, emails, addresses, or any text the user typed.\n\n## identify()\n\nTag the current session with user or account properties. Values must be safe strings. Use opaque IDs only -- a database primary key or UUID is fine, but not an email or name.\n\n```ts\nimport { identify } from 'flusterduck'\n\n// After login\nidentify({\n user_id: 'usr_8f3a2c91',\n plan: 'scale',\n account_age_days: '42',\n})\n```\n\nDo not pass `user@example.com` or `\"Alice Johnson\"` as any property. Flusterduck doesn't know which of your values are personally identifying, so that's on you.\n\n## setConsent()\n\nUpdate consent state after initialization. Called when a user accepts or rejects your cookie/tracking prompt.\n\n```ts\nimport { setConsent } from 'flusterduck'\n\nsetConsent(true) // start tracking\nsetConsent(false) // pause tracking, flush buffer\n```\n\nFor strict pre-consent gating, wait to call `init()` until the user accepts, or use a framework wrapper with `enabled: false`. Calling `setConsent(false)` on an active session flushes the buffer and stops collection immediately.\n\n## optOut()\n\nOpt the current user out for the session. Semantically clearer than `setConsent(false)` when the user has explicitly requested no tracking, versus consent not yet collected.\n\n```ts\nimport { optOut } from 'flusterduck'\n\noptOut()\n```\n\n## destroy()\n\nShut down the SDK, remove all listeners, clear the session buffer. Use this if you need to completely tear down Flusterduck from a running app instance -- for example, in a test environment or an embedded widget that gets unmounted.\n\n```ts\nimport { destroy } from 'flusterduck'\n\ndestroy()\n```\n\nCalling `init()` after `destroy()` reinitializes a fresh session.\n\n## TypeScript\n\nThe package ships full type definitions. If you want compile-time safety on signal types:\n\n```ts\nimport { signal } from 'flusterduck'\nimport type { SignalType } from 'flusterduck'\n\nconst type: SignalType = 'rage_click' // autocomplete across all 33 types\nsignal(type, { selector: '#submit-btn' })\n```\n\n## Common mistakes\n\n**Passing a secret key.** The SDK checks the key prefix on `init()` and will refuse to initialize with `fd_sec_` or `fd_mcp_` keys. It logs a console error pointing to this page.\n\n**Calling `init()` in a server component.** The SDK is browser-only. Calling `init()` in a Next.js Server Component, an edge function, or any non-browser context will throw. Gate it with `typeof window !== 'undefined'` if needed, or use the `@flusterduck/next` wrapper which handles this automatically.\n\n**Calling `identify()` before initialization.** `identify()` is a no-op until `init()` has completed. Call it as early as possible after login and after the SDK is initialized.\n"
|
|
329
392
|
},
|
|
330
393
|
{
|
|
331
394
|
"slug": "react-to-signals",
|
|
@@ -339,7 +402,7 @@ All plans include a 7-day free trial. No credit card required.
|
|
|
339
402
|
"title": "CLI reference",
|
|
340
403
|
"description": "Detect your framework, install the right packages, and inject the init call automatically.",
|
|
341
404
|
"group": "SDK & frameworks",
|
|
342
|
-
"content":
|
|
405
|
+
"content": '# CLI Reference\n\n```bash\nnpx flusterduck-cli init\n```\n\nDetects your framework, installs the right packages, and injects the init call into the correct entry file. With no `--key`, it offers to **sign you in with your browser**: a tab opens, you pick or create a site on flusterduck.com, and the key comes back to the terminal automatically \u2014 no dashboard round-trip, nothing to copy. (Creating a new site returns its key directly; an existing site asks you to paste the `fd_pub_` key already in its script tag.)\n\nAt the end, init offers to **verify the install live**: start your app, click around, and it watches for the first event to arrive \u2014 so a wrong-but-well-formed key can never fail silently.\n\nYou don\'t need to install the CLI globally. `npx` pulls the latest version each time.\n\n## Options\n\n### `--key fd_pub_xxxx`\n\nSkip the key prompt:\n\n```bash\nnpx flusterduck-cli init --key fd_pub_xxxxxxxxxxxx\n```\n\nUseful in CI pipelines, onboarding scripts, and anywhere you want a non-interactive run.\n\n### `--skip-install`\n\nInject the setup code without installing packages. Use this if you\'ve already installed the SDK separately or if your package manager workflow requires a separate step.\n\n```bash\nnpx flusterduck-cli init --skip-install --key fd_pub_xxxxxxxxxxxx\n```\n\n## Framework detection\n\nThe CLI reads your `package.json` and project structure to determine your framework, then installs and injects accordingly:\n\n| Detected | Packages installed | File injected |\n|---|---|---|\n| Next.js | `flusterduck` `@flusterduck/next` | `app/layout.tsx` (App Router) or `pages/_app.tsx` (Pages Router) |\n| SvelteKit | `flusterduck` `@flusterduck/svelte` | `src/routes/+layout.svelte` |\n| Nuxt | `flusterduck` `@flusterduck/nuxt` | `nuxt.config.ts` |\n| React (Vite / CRA) | `flusterduck` | `src/main.tsx` or `src/index.tsx` |\n| Generic / unknown | `flusterduck` | No injection |\n\nIf the CLI can\'t determine your framework, it installs the core SDK and exits with a code snippet to add manually.\n\n## What gets injected\n\nThe injected code is a single import and a single init call. For Next.js App Router:\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from \'@flusterduck/next\'\n\n// Added inside your RootLayout body:\n<FlusterduckScript apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!} />\n```\n\nFor vanilla React:\n\n```tsx\n// src/main.tsx\nimport { init } from \'flusterduck\'\ninit({ key: process.env.VITE_FLUSTERDUCK_KEY! })\n```\n\nThe CLI doesn\'t rewrite existing imports or touch your component tree. If Flusterduck is already present in the target file, it skips injection and tells you.\n\n## Environment variable\n\nThe injected code references an environment variable, not a literal key. After running the CLI, set the variable:\n\n```bash\n# .env.local (Next.js)\nNEXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# .env (Vite / SvelteKit)\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nThe CLI output tells you the exact variable name for your framework.\n\n## Reading your data\n\nBeyond `init`, the CLI reads and manages friction data straight from your terminal. The binary answers to two names \u2014 `flusterduck` and `duck` \u2014 so `duck status` and `duck issues` both work.\n\nSign in once and every command authenticates automatically:\n\n```bash\nduck login # browser sign-in (mints a key, nothing to copy) or paste with hidden input\nduck logout # removes the saved key\n```\n\nThe browser option opens flusterduck.com, you pick the site this terminal should manage, and a freshly minted key is sent straight to the CLI on your machine (never through a URL). Either way `login` verifies the key against the API before saving, stores it in a `0600` file under your config directory (`~/.config/flusterduck/credentials.json`), and remembers the key\'s site \u2014 so a site-scoped key makes `--site` optional everywhere. Precedence when both exist: `--key` flag, then `FLUSTERDUCK_SECRET_KEY`, then the saved login. Override the API base with `--api` or `FLUSTERDUCK_API_URL`. Every command takes `--json` for raw output.\n\n```bash\nduck login\nduck issues --status open # no --key, no --site\nduck deploy notify # same\n```\n\n### `scores`\n\nPer-page confusion scores for a site, ranked by friction.\n\n```bash\nflusterduck scores --site <site_id>\n```\n\n### `issues`\n\nUX issues detected on a site. Filter with `--status` (`open`, `triaged`, `in_progress`, `resolved`, `verified`, `ignored`, `regressed`) and cap with `--limit`.\n\n```bash\nflusterduck issues --site <site_id> --status open --limit 20\n```\n\n### `insights`\n\nThe confused-vs-calm conversion gap: how much less confused sessions convert than calm ones, the pages and traffic sources hit hardest, and the ranked insights. Pass `--days` (1-90, default 7) to set the window.\n\n```bash\nflusterduck insights --site <site_id> --days 30\n```\n\nThis needs a conversion event wired so Flusterduck knows what "success" is \u2014 see [Conversion trigger](./conversion-trigger).\n\n### `issue`\n\nManage a single issue from the terminal. Verbs: `resolve`, `ignore`, `reopen`, `start` (moves it to in progress). Attach context with `--note`.\n\n```bash\nflusterduck issue resolve 7f2c9d4a-... --note "Fixed in #142"\nflusterduck issue ignore 7f2c9d4a-... --note "Third-party widget, can\'t fix"\n```\n\n### `status`\n\nIs Flusterduck receiving data for a key? Authenticates with the **publishable** key (`--key fd_pub_...` or `FLUSTERDUCK_PUBLISHABLE_KEY`), so it works before you\'ve ever opened the dashboard. `--wait` polls for up to 3 minutes until the first event lands.\n\n```bash\nflusterduck status --key fd_pub_xxxxxxxxxxxx --wait\n```\n\n### `deploy notify`\n\nRecord a deploy so Flusterduck captures before/after confusion and verifies your fixes. Run it from CI after each production deploy \u2014 commit hash, author, and PR number are auto-detected on GitHub Actions, Vercel, GitLab, and Bitbucket.\n\n```bash\nflusterduck deploy notify --site <site_id>\n# or outside CI:\nflusterduck deploy notify --site <site_id> --commit "$(git rev-parse HEAD)" --env production\n```\n\nSame thing, no npx: POST to [`/v1/deploys`](./deploy-correlation) directly.\n\n## Troubleshooting\n\n**"Could not automatically inject code"** -- the CLI found the entry file but couldn\'t parse it (unusual syntax, non-standard structure). Wire the init call manually following the [quickstart](./quickstart).\n\n**"Flusterduck is already configured"** -- the SDK was already detected in the target file. Nothing changed.\n\n**"Failed to install dependencies"** -- package installation failed. The CLI prints the exact install command to run yourself.\n'
|
|
343
406
|
},
|
|
344
407
|
{
|
|
345
408
|
"slug": "frameworks",
|
|
@@ -397,6 +460,13 @@ All plans include a 7-day free trial. No credit card required.
|
|
|
397
460
|
"group": "SDK & frameworks",
|
|
398
461
|
"content": "# Build Plugins\n\nThe Vite and webpack plugins handle deploy tagging and source map upload at build time. You don't need them to use Flusterduck. Add them when you want automatic deploy recording from your build pipeline or when you want stack traces in issue evidence to resolve to original source.\n\n## Vite\n\n```bash\npnpm add -D flusterduck-vite-plugin\n```\n\n```ts\n// vite.config.ts\nimport { defineConfig } from 'vite'\nimport { flusterduck } from 'flusterduck-vite-plugin'\n\nexport default defineConfig({\n plugins: [\n flusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n }),\n ],\n})\n```\n\n## webpack\n\n```bash\npnpm add -D flusterduck-webpack-plugin\n```\n\n```js\n// webpack.config.js\nconst { FlusterduckPlugin } = require('flusterduck-webpack-plugin')\n\nmodule.exports = {\n plugins: [\n new FlusterduckPlugin({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n }),\n ],\n}\n```\n\nBoth plugins accept the same options. The examples below use Vite syntax, but the options are identical for webpack.\n\n## Options\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `secretKey` | `string` | required | Your `fd_sec_` secret key. Never expose this client-side. |\n| `release` | `string` | git SHA | Version string attached to the deploy record. Defaults to `git rev-parse HEAD`. |\n| `environment` | `string` | `NODE_ENV` | `\"production\"`, `\"staging\"`, `\"development\"` |\n| `sourceMaps` | `boolean` | `true` | Upload source maps after build. |\n| `deleteSourceMaps` | `boolean` | `false` | Delete uploaded source maps from the output directory after upload. |\n| `include` | `string[]` | `[\"dist\"]` | Directories to scan for source map files. |\n| `dryRun` | `boolean` | `false` | Run the plugin without uploading or recording a deploy. Useful for verifying config. |\n\n## Deploy recording\n\nThe plugin records a deploy automatically when the build completes. You don't need the CI curl command from [Deploy Correlation](./deploy-correlation) if you're using the build plugin. Use one or the other, not both.\n\n```ts\nflusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n release: process.env.VITE_APP_VERSION || 'unknown',\n environment: 'production',\n})\n```\n\nThe plugin POSTs to `/v1/deploys` at the end of the build with `version` set to `release` and `environment` set accordingly. `confusion_before` is captured at that moment.\n\n## Source maps\n\nWhen `sourceMaps: true` (the default), the plugin uploads `.map` files after the build. Issue evidence in the dashboard will show original file names and line numbers instead of minified bundle references.\n\nSource maps are uploaded to Flusterduck's servers and stored against the `release` version string. They're used only to resolve stack traces in issue evidence. They aren't accessible from the client.\n\n### Keeping source maps off your CDN\n\nSet `deleteSourceMaps: true` to remove map files from the output directory after upload. They'll be used for stack trace resolution but won't be served to browsers or end up in your CDN cache.\n\n```ts\nflusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n sourceMaps: true,\n deleteSourceMaps: true,\n})\n```\n\n### Including only specific directories\n\n```ts\nflusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n include: ['dist/assets'],\n})\n```\n\n## Environment variable setup\n\nStore `FLUSTERDUCK_SECRET_KEY` in your build environment, not in your codebase. The secret key should never appear in client bundles.\n\nFor local development, add it to `.env.local` (which your `.gitignore` should exclude):\n\n```bash\n# .env.local\nFLUSTERDUCK_SECRET_KEY=fd_sec_xxxxxxxxxxxx\n```\n\nIn CI, add it as an environment secret:\n- GitHub Actions: Settings > Secrets > Actions > New repository secret\n- Vercel: Project Settings > Environment Variables\n- Netlify: Site settings > Build > Environment variables\n\n## Dry run\n\nVerify plugin configuration without uploading anything:\n\n```ts\nflusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n dryRun: process.env.NODE_ENV !== 'production',\n})\n```\n\nWith `dryRun: true`, the plugin logs what it would upload and what deploy record it would create, then exits without sending any requests. Useful for testing your config in staging before enabling in production.\n\n## Next.js\n\nNext.js uses webpack under the hood. Use the webpack plugin in `next.config.js`:\n\n```js\n// next.config.js\nconst { FlusterduckPlugin } = require('flusterduck-webpack-plugin')\n\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n webpack: (config, { isServer, buildId }) => {\n if (!isServer) {\n config.plugins.push(\n new FlusterduckPlugin({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n release: buildId,\n environment: process.env.NODE_ENV,\n deleteSourceMaps: true,\n })\n )\n }\n return config\n },\n}\n\nmodule.exports = nextConfig\n```\n\nScope it to `!isServer` to avoid running the plugin twice per build (Next.js runs separate webpack compilations for client and server).\n\nUse `buildId` as the release version. Next.js generates a stable, unique build ID per deployment.\n"
|
|
399
462
|
},
|
|
463
|
+
{
|
|
464
|
+
"slug": "signals-vs-detectors",
|
|
465
|
+
"title": "Detectors, signals & friction types",
|
|
466
|
+
"description": "The three words that come up everywhere, explained in plain English: detectors watch, signals report, friction types organize.",
|
|
467
|
+
"group": "Product",
|
|
468
|
+
"content": "# Detectors, signals, and friction types\n\nThree words come up everywhere in Flusterduck \u2014 detectors, signals, and friction types \u2014 and they're easy to mix up. Here's the difference in plain English, with one example carried all the way through.\n\n## The one-sentence version\n\n**Detectors watch. Signals report. Friction types organize.** Repeated signals then cluster into **issues** \u2014 the ranked list you actually work from.\n\n## Detectors: the watchers\n\nA detector is a small piece of code inside the SDK that watches for one specific pattern of struggle. Nothing more.\n\nThe rage-click detector watches for someone clicking the same spot again and again in frustration. The dead-click detector watches for taps on things that look clickable but aren't. The ghost-toggle detector watches for switches that get clicked but never change. About a hundred of these watchers ship in the SDK, and every one of them runs automatically from the single script tag \u2014 there is nothing to configure, instrument, or turn on.\n\nThink of detectors as a hundred quiet observers, each trained to notice exactly one way a person can get stuck.\n\n## Signals: the reports\n\nA signal is what a detector sends the moment it sees its pattern \u2014 one timestamped observation from one real person's session:\n\n> Rage click on **\"Apply coupon\"** \xB7 `/checkout` \xB7 2:51 PM\n\nThat's a single signal: which pattern fired, on which element (its visible label, PII-redacted in the browser before anything is sent), on which page, at what moment, with a confidence score for how certain the detector is. Signals are the raw sightings \u2014 plentiful, small, and individually not very dramatic. One person rage-clicking once might mean nothing. The value comes from what happens next.\n\n## Friction types: the filing system\n\nA friction type is the named category a signal belongs to \u2014 the label Flusterduck's scoring, dashboard, and AI use to group, weight, and rank what the detectors saw. `rage_click`, `dead_click`, `form_abandonment`, `no_op_interaction` \u2014 there are 115 of them, each with a weight reflecting how strongly it predicts a lost conversion (a dead-end checkout submit weighs far more than a stray scroll bounce).\n\nFriction types are why the dashboard can say something useful instead of showing you a firehose: every sighting is filed, weighted, and comparable.\n\n## Why are there more detectors than friction types?\n\nBecause several watchers can file reports under one category. Different detectors catch mobile taps that miss their target, thumb-zone misses, and near-miss clicks from different angles \u2014 and their reports can land in the same friction type. The detector count grows every time we learn a new way users get stuck (it's an iterative process \u2014 every real-world bug we see becomes a detector); the friction-type list grows more slowly, only when a genuinely new *category* of struggle shows up.\n\n## From signals to issues\n\nSignals don't reach your issue list one at a time. When the same friction type keeps firing on the same page or element across multiple sessions, Flusterduck clusters those signals into a single **issue** \u2014 with a plain-language title, a severity bounded by how many users it actually reached, a confidence grade, a revenue estimate, and (on paid plans) an AI diagnosis grounded in your page's actual content. That ranked issue list is the product; detectors, signals, and friction types are the machinery underneath it.\n\nRead more: [Signals reference](/signals) \xB7 [Issues](/issues) \xB7 [Scoring](/scoring)\n\n## FAQ\n\n**Do I need to configure any of this?** No. All detectors run automatically from one script tag. You can turn individual detectors off in the SDK config if you ever need to, but the default is everything on.\n\n**Does more detectors mean more data sent from my site?** Barely \u2014 signals are tiny structured events (a name, a selector, a redacted label, timestamps), not recordings. There is no session replay and no page content captured from your visitors' sessions.\n\n**Can one user action fire multiple detectors?** Yes, and that's useful: a broken button might draw rage clicks *and* no-op interactions *and* an error signal. Co-occurring signals on the same element are strong evidence they share one root cause, and the clustering treats them that way.\n"
|
|
469
|
+
},
|
|
400
470
|
{
|
|
401
471
|
"slug": "signals",
|
|
402
472
|
"title": "Signals",
|
|
@@ -451,14 +521,21 @@ All plans include a 7-day free trial. No credit card required.
|
|
|
451
521
|
"title": "Revenue impact",
|
|
452
522
|
"description": "Wire conversion events with track() and Flusterduck puts dollar amounts on UX issues.",
|
|
453
523
|
"group": "Product",
|
|
454
|
-
"content": '# Revenue Impact\n\nWire conversion events with `track()` and Flusterduck starts putting dollar amounts on UX issues. Instead of "47 users are rage-clicking the checkout button," you get "47 users are rage-clicking the checkout button and we estimate $5,200/month in lost revenue from sessions with that pattern."\n\nThat\'s the number that gets engineers to prioritize UX bugs over feature work.\n\n## What to track\n\nThree events power revenue estimates:\n\n```ts\nimport { track } from \'flusterduck\'\n\n// User selects a plan or clicks an upgrade CTA\ntrack(\'plan_intent\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n\n// Purchase completed\ntrack(\'subscription_started\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n\n// Checkout started but not completed\ntrack(\'checkout_abandoned\', {\n plan_id: \'grow\',\n amount_cents: 3900,\n})\n```\n\nWhere each one belongs in your code:\n\n**`plan_intent`**: when a user clicks a pricing CTA or selects a plan tier. Before the payment form. This tells the engine what was at stake in sessions that didn\'t convert.\n\n**`subscription_started`**: in your post-payment success handler or your Stripe webhook. This establishes the baseline conversion rate.\n\n**`checkout_abandoned`**: when a user leaves the checkout flow. Use your payment provider\'s abandon detection or wire it to `onbeforeunload` on your checkout page.\n\n## Safe properties\n\nNever pass PII, form values, or any user-typed content. The engine only needs these:\n\n| Property | Type | Notes |\n|---|---|---|\n| `plan_id` | string | Your internal plan identifier (`"grow"`, `"scale"`, `"enterprise"`) |\n| `amount_cents` | number | Integer. In cents, not dollars. `9900` = $99.00 |\n| `billing` | string | `"monthly"` or `"annual"` |\n| `currency` | string | ISO 4217 code. Defaults to `"USD"` |\n| `quantity` | number | Seat count, unit count |\n| `product_id` | string | For multi-product apps |\n\n## How the estimate works\n\nThe engine correlates friction patterns with conversion outcomes:\n\n1. For each open issue, it identifies the sessions containing that friction pattern.\n2. It compares the conversion rate of those sessions against sessions on the same page with no friction signals.\n3. The conversion gap gets monetized using the average `amount_cents` from recent `subscription_started` events.\n4. That\'s projected across the monthly session volume for that page.\n\nThe result is directionally accurate, not exact. It assumes the friction is causing the conversion gap, which is usually right but not always. A page that\'s down or slow will produce high friction signals and low conversions, but the fix isn\'t a UX change. Use the revenue estimate as a prioritization signal, not a forecast.\n\n## Reading revenue estimates\n\n```bash\ncurl "https://api.flusterduck.com/v1/revenue" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n```json\n{\n "total_at_risk_monthly": 8400,\n "currency": "USD",\n "issues": [\n {\n "issue_id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on complete purchase button",\n "page": "/checkout",\n "revenue_at_risk_monthly": 5200,\n "affected_sessions_pct": 14,\n "conversion_gap_pct": 8.3\n },\n {\n "issue_id": "iss_xxxxxxxxxxxx",\n "title": "Form abandonment at billing step",\n "page": "/checkout",\n "revenue_at_risk_monthly": 3200,\n "affected_sessions_pct": 9,\n "conversion_gap_pct": 5.1\n }\n ]\n}\n```\n\n`affected_sessions_pct` is the percentage of checkout sessions that contain this friction pattern. `conversion_gap_pct` is the difference in conversion rate between affected and unaffected sessions.\n\nRevenue estimates are only populated when:\n- You\'ve called `track(\'subscription_started\', ...)` at least 50 times in the past 30 days (the engine needs enough data to establish a baseline conversion rate)\n- There are open issues on pages where conversion events were tracked\n- The issue\'s affected sessions have a statistically meaningful conversion gap\n\nIf the `/revenue` endpoint returns empty `issues`, you either haven\'t tracked enough conversions yet or there aren\'t enough sessions to compute a meaningful gap.\n\n## Revenue via MCP\n\n```\nWhat\'s the revenue impact of the current open issues?\nWhich issue is costing us the most?\nRank the open issues by revenue at risk.\n```\n\nThe `get_revenue_impact` tool returns the same data as the API endpoint, formatted for your AI assistant to reason about.\n\n## Annual vs monthly\n\nThe estimate uses `billing` to annualize correctly. If most of your conversions are annual, the per-session value is higher and the revenue-at-risk number will reflect that. Pass `billing: "annual"` in `subscription_started` events for annual subscribers and `billing: "monthly"` for monthly subscribers.\n\nIf you don\'t pass `billing`, the engine assumes monthly.\n\n## When estimates look too low or too high\n\n**Too low**: You may not be calling `track(\'checkout_abandoned\', ...)`. Without explicit abandonment events, the engine has to infer them from sessions that had `plan_intent` but no `subscription_started`. That inference is less precise than an explicit event.\n\n**Too high**: Check whether your `subscription_started` events are firing in test or CI sessions. If they are, the engine\'s baseline conversion rate will be inflated and the gap will appear larger than it is. Pass `environment: "development"` to your SDK init in non-production environments to keep test data out of production scores.\n'
|
|
524
|
+
"content": '# Revenue Impact\n\nWire conversion events with `track()` and Flusterduck starts putting dollar amounts on UX issues. Instead of "47 users are rage-clicking the checkout button," you get "47 users are rage-clicking the checkout button and we estimate $5,200/month in lost revenue from sessions with that pattern."\n\nThat\'s the number that gets engineers to prioritize UX bugs over feature work.\n\n## What to track\n\nThree events power revenue estimates:\n\n```ts\nimport { track } from \'flusterduck\'\n\n// User selects a plan or clicks an upgrade CTA\ntrack(\'plan_intent\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n\n// Purchase completed\ntrack(\'subscription_started\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n\n// Checkout started but not completed\ntrack(\'checkout_abandoned\', {\n plan_id: \'grow\',\n amount_cents: 3900,\n})\n```\n\nWhere each one belongs in your code:\n\n**`plan_intent`**: when a user clicks a pricing CTA or selects a plan tier. Before the payment form. This tells the engine what was at stake in sessions that didn\'t convert.\n\n**`subscription_started`**: in your post-payment success handler or your Stripe webhook. This establishes the baseline conversion rate.\n\n**`checkout_abandoned`**: when a user leaves the checkout flow. Use your payment provider\'s abandon detection or wire it to `onbeforeunload` on your checkout page.\n\n## Safe properties\n\nNever pass PII, form values, or any user-typed content. The engine only needs these:\n\n| Property | Type | Notes |\n|---|---|---|\n| `plan_id` | string | Your internal plan identifier (`"grow"`, `"scale"`, `"enterprise"`) |\n| `amount_cents` | number | Integer. In cents, not dollars. `9900` = $99.00 |\n| `billing` | string | `"monthly"` or `"annual"` |\n| `currency` | string | ISO 4217 code. Defaults to `"USD"` |\n| `quantity` | number | Seat count, unit count |\n| `product_id` | string | For multi-product apps |\n\n## How the estimate works\n\nThe engine correlates friction patterns with conversion outcomes:\n\n1. For each open issue, it identifies the sessions containing that friction pattern.\n2. It compares the conversion rate of those sessions against sessions on the same page with no friction signals.\n3. The conversion gap gets monetized using the average `amount_cents` from recent `subscription_started` events.\n4. That\'s projected across the monthly session volume for that page.\n\nThe result is directionally accurate, not exact. It assumes the friction is causing the conversion gap, which is usually right but not always. A page that\'s down or slow will produce high friction signals and low conversions, but the fix isn\'t a UX change. Use the revenue estimate as a prioritization signal, not a forecast.\n\n## Reading revenue estimates\n\n```bash\ncurl "https://api.flusterduck.com/v1/revenue" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n```json\n{\n "total_at_risk_monthly": 8400,\n "currency": "USD",\n "issues": [\n {\n "issue_id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on complete purchase button",\n "page": "/checkout",\n "revenue_at_risk_monthly": 5200,\n "affected_sessions_pct": 14,\n "conversion_gap_pct": 8.3\n },\n {\n "issue_id": "iss_xxxxxxxxxxxx",\n "title": "Form abandonment at billing step",\n "page": "/checkout",\n "revenue_at_risk_monthly": 3200,\n "affected_sessions_pct": 9,\n "conversion_gap_pct": 5.1\n }\n ]\n}\n```\n\n`affected_sessions_pct` is the percentage of checkout sessions that contain this friction pattern. `conversion_gap_pct` is the difference in conversion rate between affected and unaffected sessions.\n\nRevenue estimates are only populated when:\n- You\'ve called `track(\'subscription_started\', ...)` at least 50 times in the past 30 days (the engine needs enough data to establish a baseline conversion rate)\n- There are open issues on pages where conversion events were tracked\n- The issue\'s affected sessions have a statistically meaningful conversion gap\n\nIf the `/revenue` endpoint returns empty `issues`, you either haven\'t tracked enough conversions yet or there aren\'t enough sessions to compute a meaningful gap.\n\n## Revenue via MCP\n\n```\nWhat\'s the revenue impact of the current open issues?\nWhich issue is costing us the most?\nRank the open issues by revenue at risk.\n```\n\nThe `get_revenue_impact` tool returns the same data as the API endpoint, formatted for your AI assistant to reason about.\n\n## Annual vs monthly\n\nThe estimate uses `billing` to annualize correctly. If most of your conversions are annual, the per-session value is higher and the revenue-at-risk number will reflect that. Pass `billing: "annual"` in `subscription_started` events for annual subscribers and `billing: "monthly"` for monthly subscribers.\n\nIf you don\'t pass `billing`, the engine assumes monthly.\n\n## When estimates look too low or too high\n\n**Too low**: You may not be calling `track(\'checkout_abandoned\', ...)`. Without explicit abandonment events, the engine has to infer them from sessions that had `plan_intent` but no `subscription_started`. That inference is less precise than an explicit event.\n\n**Too high**: Check whether your `subscription_started` events are firing in test or CI sessions. If they are, the engine\'s baseline conversion rate will be inflated and the gap will appear larger than it is. Pass `environment: "development"` to your SDK init in non-production environments to keep test data out of production scores.\n\n## See also\n\nThe same conversion events power the [Conversion trigger](./conversion-trigger) \u2014 the confused-vs-calm analysis that shows, site-wide, how much less confused sessions convert than calm ones. Revenue impact dollarizes individual issues; the conversion trigger proves confusion is costing conversions and pinpoints where. Wire the event once and both get sharper.\n'
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
"slug": "conversion-trigger",
|
|
528
|
+
"title": "Conversion trigger",
|
|
529
|
+
"description": "Tell Flusterduck what success is, then see how much less confused sessions convert than calm ones.",
|
|
530
|
+
"group": "Product",
|
|
531
|
+
"content": '# Conversion trigger\n\nFlusterduck already knows where people get confused. The conversion trigger tells it what *success* looks like on your site \u2014 the single event that means a visitor did the thing you wanted. Once that\'s wired, Flusterduck can prove the part that matters to the business: **confused sessions convert less than calm ones**, and it can put a number on the gap.\n\nThis page covers the two steps to set it up, then the confused-vs-calm insight it unlocks.\n\n## Why this exists\n\nConfusion scores tell you *where* friction is. They don\'t, on their own, tell you whether that friction costs you anything. A page can be a little confusing and still convert fine; another can look calm and quietly leak sign-ups.\n\nThe conversion trigger closes that loop. When Flusterduck knows which event counts as a conversion, it splits every session into two cohorts \u2014 **confused** (produced at least one friction signal) and **calm** (zero friction signals) \u2014 and compares how often each cohort converts. The difference is the money story: "confused sessions convert 12 points lower than calm ones, and it\'s worst on `/checkout`."\n\n## Step 1 \u2014 Fire the conversion event from the SDK\n\nConversions are ordinary business events, sent with `track()`. Call it the moment a conversion actually completes \u2014 in your post-payment success handler, your thank-you page, or wherever you already know the visitor succeeded.\n\n```ts\nimport { track } from \'flusterduck\'\n\n// In your success handler, once the purchase is confirmed\ntrack(\'purchase_completed\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n```\n\nFlusterduck recognizes three realized-purchase events out of the box:\n\n| Event | Fire it when |\n|---|---|\n| `checkout_completed` | An order or checkout finishes |\n| `purchase_completed` | A one-time purchase is confirmed |\n| `subscription_started` | A subscription or trial-to-paid conversion completes |\n\nPick the one that best names your success moment. If you have several (a store with both one-off orders and subscriptions), you can fire more than one \u2014 Step 2 lets you choose which counts.\n\nThe same privacy rules as [Revenue impact](./revenue) apply: never pass PII, form values, or typed content. Safe properties are `plan_id`, `amount_cents` (integer cents), `billing`, `currency`, `quantity`, `product_id`.\n\n> The SDK sends `track(\'purchase_completed\', \u2026)` as a business event named `purchase_completed`. That name is exactly what you\'ll reference in Step 2.\n\n## Step 2 \u2014 Choose which event counts as the conversion\n\nTell Flusterduck which business event is *the* conversion for this site. Open **Settings \u2192 Revenue** and set the **Conversion goal** field to the event name you fired in Step 1 (for example, `purchase_completed`).\n\nUnder the hood this is stored as `revenue_config.conversion_goal` on the site\'s production SDK config. If you manage config through the API, set it with the `manage` write API:\n\n```bash\ncurl -X PATCH "https://api.flusterduck.com/v1/manage/sdk-config" \\\n -H "Authorization: Bearer <jwt>" \\\n -H "Content-Type: application/json" \\\n -d \'{\n "site_id": "<site_id>",\n "environment": "production",\n "revenue_config": { "conversion_goal": "purchase_completed" }\n }\'\n```\n\n**Leaving the goal unset is a valid choice.** With no `conversion_goal`, Flusterduck counts *any* realized-purchase event (`checkout_completed`, `purchase_completed`, or `subscription_started`) as a conversion. Set an explicit goal when you fire several of those events but only one of them is the outcome you care about \u2014 the goal narrows the definition to exactly that event.\n\n## The insight it unlocks\n\nWith a conversion firing, the confused-vs-calm analysis becomes available. It reports:\n\n- **Cohort comparison** \u2014 conversion rate, average session duration, pages per session, and bounce rate for confused vs calm sessions, plus the deltas between them.\n- **By page** \u2014 where the conversion gap is widest, so you know which confusing page costs the most conversions.\n- **By source** \u2014 which traffic sources (UTM source / referrer host) lose the most conversions to confusion.\n- **Ranked insights** \u2014 pre-written, narratable findings such as "Confused visitors convert 12 points lower." Each carries a confidence flag; a cohort under the minimum sample size is marked low-confidence so you don\'t over-read early data.\n\n### Read it three ways\n\n**REST** \u2014 `GET /v1/query/insights?site_id=<id>&days=7` (window is 1\u201390 days, default 7):\n\n```bash\ncurl "https://api.flusterduck.com/v1/query/insights?site_id=<site_id>&days=30" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n**CLI** \u2014 the [CLI](./cli) prints a readable summary: the headline gap, the top per-page gaps, the hardest-hit sources, and the ranked insights.\n\n```bash\nflusterduck insights --site <site_id> --days 30\n```\n\n**MCP** \u2014 the `get_conversion_insights` tool (local [MCP server](./mcp) and the remote Cloudflare worker) returns the same analysis for your AI assistant, so you can just ask:\n\n```\nHow much less do confused sessions convert than calm ones?\nWhich page loses the most conversions to confusion?\nWhich traffic source is hit hardest by confusion?\n```\n\n## Sampling and confidence\n\nThe engine needs a floor of sessions in **each** cohort before it will make a confident claim (the `min_sample` in the response, 30 by default). Until both cohorts clear it, the analysis still returns, but the headline insight reads "Still gathering data" and cohorts are flagged `low_confidence`. This is deliberate: a 3-session cohort can show a wild, meaningless gap. Give it real traffic before acting on the number.\n\n## Relationship to revenue impact\n\nThe conversion trigger and [Revenue impact](./revenue) are complementary. Revenue impact attaches dollar figures to individual open issues using the `amount_cents` you pass. The confused-vs-calm insight is the broader, site-wide picture: it doesn\'t need dollar amounts \u2014 only a conversion event \u2014 to show that confusion is depressing conversions and where. Wire the conversion event once and both get sharper.\n'
|
|
455
532
|
},
|
|
456
533
|
{
|
|
457
534
|
"slug": "mcp",
|
|
458
535
|
"title": "MCP integration",
|
|
459
536
|
"description": "Give your AI assistant read access to scores, issues, journeys, and revenue leakage.",
|
|
460
537
|
"group": "Integrations",
|
|
461
|
-
"content": '# MCP Integration\n\nSet this up once and you stop opening the dashboard to check on friction data. Ask your AI assistant:\n\n> "What\'s broken on checkout right now?"\n\nIt calls `get_site_context`, pulls your live scores and open issues, and tells you. Post-deploy checks, issue triage, weekly summaries. All without touching a UI.\n\n## Fastest install\n\nNo global install is needed \u2014 `npx` fetches the server on first run. Grab an MCP key and your site ID from the dashboard (Settings \u2192 API keys), then:\n\n```bash\nclaude mcp add flusterduck \\\n --env FLUSTERDUCK_MCP_KEY=fd_mcp_xxxxxxxxxxxx \\\n --env FLUSTERDUCK_SITE_ID=your-site-id \\\n -- npx -y @flusterduck/mcp-server\n```\n\nPrefer the hosted server? Sign in with OAuth, no key to copy:\n\n```bash\nclaude mcp add --transport http flusterduck https://mcp.flusterduck.com/mcp\n```\n\n## Setup\n\n### Claude Desktop\n\nAdd to `~/Library/Application Support/Claude/claude_desktop_config.json` (npx pulls the server automatically \u2014 nothing to install first):\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\nRestart Claude Desktop. Tools are available immediately.\n\n### Claude Code CLI\n\n```bash\nclaude mcp add flusterduck \\\n --env FLUSTERDUCK_MCP_KEY=fd_mcp_xxxxxxxxxxxx \\\n --env FLUSTERDUCK_SITE_ID=your-site-id \\\n -- npx -y @flusterduck/mcp-server\n```\n\nOr add directly to `~/.claude/settings.json`:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### Cursor\n\n`~/.cursor/mcp.json` (global) or `.cursor/mcp.json` in the project root:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\nReload the Cursor window after saving.\n\n### Windsurf\n\n`~/.codeium/windsurf/mcp_config.json`:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### VS Code (GitHub Copilot)\n\n`.vscode/mcp.json` in the workspace:\n\n```json\n{\n "servers": {\n "flusterduck": {\n "type": "stdio",\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### Remote server\n\nConnect to the hosted Cloudflare Worker endpoint using your `fd_mcp_` key. The worker handles OAuth and scope validation automatically. Get the endpoint URL from Settings > Integrations > MCP.\n\n## Keys\n\nMCP keys start with `fd_mcp_`. Create one under Settings > API Keys.\n\n**Read-only** (`mcp:read`): all query tools and prompts. Can\'t write anything. Start here.\n\n**Read + write** (`mcp:read` + `manage:write`): adds `update_issue`, `update_alert`, `add_annotation`, `create_alert_rule`, `update_alert_rule`, and `delete_alert_rule`. Needed for triage and annotation workflows.\n\n## Prompts\n\nThe prompts are the highest-value part. They\'re multi-step workflows built into the server. The AI calls several tools in sequence and synthesizes the result into a useful answer. Use prompts for the common cases; use raw tools when you need something specific.\n\n### post_deploy_check\n\nFinds your most recent deploy, compares confusion scores before and after, identifies pages where friction spiked, and returns PASS / WARN / FAIL. Writes an annotation automatically on FAIL.\n\nRun this after every deploy. Takes about 30 seconds.\n\n### triage_open_issues\n\nScans open issues by signal count, moves each to the right status (`triaged`, `in_progress`, or `ignored`), adds a triage note to each, and writes a summary annotation. Requires `manage:write`.\n\nMost useful first thing Monday morning before standup.\n\n### diagnose_page\n\nFive-step diagnosis for a specific page. Returns: current friction score, top signal sources by volume, worst-performing element, likely root cause, and one concrete fix recommendation.\n\n```\npage_path: "/settings/billing"\n```\n\n### investigate_session\n\nFull investigation of a single session: event timeline in order, dominant signal types, matching open issues, and a verdict on whether the session represents a recurring pattern or an outlier.\n\n```\nsession_id: "ses_xxxxxxxxxxxx"\n```\n\n### weekly_summary\n\nA weekly UX friction digest formatted for a PM or eng lead: score trends, newly opened issues, open alerts, revenue at risk, top three recommendations. Under 300 words.\n\nGood to run before Monday standup.\n\n## What a real investigation looks like\n\nYou ask: "Did the deploy yesterday break anything on checkout?"\n\nThe AI runs this automatically:\n\n1. `get_deploys`: finds yesterday\'s deploy and its timestamp\n2. `get_page` with `/checkout`: sees the confusion score jumped from 14 to 38 in the hour after\n3. `get_issues` filtered to `open`: two issues opened within 90 minutes of the deploy, a rage click spike on `#place-order` and elevated form abandonment on the payment step\n4. `get_elements` scoped to `/checkout`: `#place-order` has 52 rage clicks in 24 hours, baseline is 5/day\n5. `diagnose_page`: returns root cause. The button appears inactive during payment processing with no visual indication, causing repeated clicking.\n6. `add_annotation`: "Deploy 2026-06-09: /checkout confusion +171%. #place-order rage clicks 10x baseline. Root cause: missing loading state on payment submit."\n\nThe whole sequence runs in about 30 seconds and leaves a permanent timeline marker.\n\n## Read tools\n\nAll require `mcp:read` scope. Start with `get_site_context`. It gives you the full picture in one call and tells you where to dig next.\n\n**Documentation** \u2014 the full Flusterduck docs are bundled into the server, so your assistant can read them before acting on data.\n\n- `list_docs`: Lists every documentation page (slug, title, group). Start here to see what\'s available.\n- `search_docs`: Full-text search across all docs. Pass `query`; returns matching pages with excerpts.\n- `get_doc`: Returns a full documentation page by `slug` (e.g. `alerts`, `scoring`, `webhooks`, `react`, `mcp`). Read a feature\'s page before acting \u2014 for example, read `alerts` before creating a rule to understand threshold semantics.\n\n**Start here**\n\n- `get_site_context`: Full snapshot of scores, open issues, active alerts, recent deploys, and top recommendations. The right first call for any investigation.\n- `get_scores`: Every tracked page ranked by current confusion score.\n- `get_recommendations`: Prioritized fix list ranked by estimated confusion reduction.\n\n**Issues and alerts**\n\n- `get_issues`: All UX issues. Filter by status: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `regressed`.\n- `get_issue`: Full detail on one issue, including evidence, session links, verification history, and deploy correlation.\n- `get_alerts`: Active and resolved alerts. Filter by `open`, `acknowledged`, or `resolved`.\n- `list_alert_rules`: All configured rules with thresholds, channels, and enabled state. Call this before creating or modifying rules.\n\n**Page deep-dives**\n\n- `get_page`: Score history, active issues, element friction, annotations, and confusion budget for one page. Pass `page: "/checkout"`.\n- `get_elements`: Element-level breakdown showing which buttons, forms, and links generate the most signals. Scope by `page` or pull site-wide.\n- `get_trends`: Confusion score over time. Pass `days` (1-90) and optionally `page`.\n- `compare_pages`: Side-by-side confusion score comparison. Pass `a` and `b` as page paths.\n- `diagnose_journey_friction`: High-friction navigation edges from recent sessions. Filter by `signal_type` or `min_friction_weight`.\n\n**Sessions and raw data**\n\n- `get_session_detail`: Full event timeline for one session in chronological order.\n- `get_heuristics`: The complete signal catalog with all 33 types, scoring weights, and thresholds.\n- `query_raw_rows`: SQL-style access to allowlisted tables (`events`, `signals`, `sessions`, `page_scores`, `score_history`, `ux_issues`, `alerts`, `deploys`).\n- `download_events_csv`: Raw event export as CSV.\n\n**Deploy correlation**\n\n- `get_deploys`: All deploys Flusterduck knows about, with `confusion_before` and `confusion_after` populated for deploys older than 5 minutes.\n- `get_revenue_impact`: Revenue impact estimates for active friction. Only populated when conversion tracking is wired.\n- `get_flows`: Page-to-page navigation edges from recent sessions.\n\n**Org-level** (requires org-scoped key)\n\n- `get_audit_log`: Organization audit log.\n- `get_degradation`: Active and recent backend degradation events. Also available to site-scoped keys, since degradation is operational health rather than tenant data.\n- `get_webhook_deliveries`: Outbound webhook delivery history and failure details.\n\n## Write tools\n\nAll require `manage:write` scope.\n\n**`update_issue`**: Change status, add a triage note, or assign an issue.\n\n```\nissue_id: "iss_3a7f2c9d4e1b"\nstatus: "triaged"\nnote: "Confirmed on Safari iOS 17. Disabled-state styling on #place-order doesn\'t communicate that payment is processing."\nassigned_to: "eng-lead"\n```\n\nStatus options: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `ignored`.\n\n**`update_alert`**: Acknowledge, mark investigating, or resolve an alert.\n\n```\nalert_id: "alt_9b5e1f4c2d8a"\nstatus: "resolved"\nresolved_reason: "Deploy 2026-06-09 added a spinner and disabled state to the payment submit button."\n```\n\n**`add_annotation`**: Write a timeline marker visible to the whole team.\n\n```\nmessage: "Redesigned billing flow launched. Monitoring confusion score on /settings/billing."\n```\n\n**`create_alert_rule`**: Create a new alert rule.\n\n```\nname: "Checkout rage click spike"\ntrigger_type: "spike"\nthreshold: 25\ncooldown_minutes: 60\nchannels: ["email", "slack"]\npage_pattern: "/checkout*"\n```\n\n**`update_alert_rule`**: Update an existing rule. Use `enabled: false` to silence it temporarily rather than deleting.\n\n**`delete_alert_rule`**: Permanently remove a rule. Can\'t be undone.\n\n## Questions that work well\n\n- What\'s broken on checkout right now?\n- Did the last deploy make things worse?\n- Which page has the most dead clicks this week?\n- Triage the open issues and tell me what to fix first.\n- Write a friction summary for the PM standup.\n- Which sessions show rage clicks on the upgrade button?\n- How does this week\'s confusion score compare to last week?\n- Create a spike alert for the pricing page and notify Slack.\n- What\'s the revenue impact of the current open issues?\n- Which elements on `/onboarding` are generating the most friction?\n'
|
|
538
|
+
"content": '# MCP Integration\n\nSet this up once and you stop opening the dashboard to check on friction data. Ask your AI assistant:\n\n> "What\'s broken on checkout right now?"\n\nIt calls `get_site_context`, pulls your live scores and open issues, and tells you. Post-deploy checks, issue triage, weekly summaries. All without touching a UI.\n\n## Fastest install\n\nThe hosted server needs no keys and no install \u2014 add the URL and sign in with your Flusterduck account when your client prompts you:\n\n```bash\nclaude mcp add --transport http flusterduck https://mcp.flusterduck.com/mcp\n```\n\nPrefer running the server locally (stdio)? `npx` fetches it on first run. Grab an MCP key and your site ID from the dashboard (Settings \u2192 API keys), then:\n\n```bash\nclaude mcp add flusterduck \\\n --env FLUSTERDUCK_MCP_KEY=fd_mcp_xxxxxxxxxxxx \\\n --env FLUSTERDUCK_SITE_ID=your-site-id \\\n -- npx -y @flusterduck/mcp-server\n```\n\n## Setup\n\n### Claude Desktop\n\nAdd to `~/Library/Application Support/Claude/claude_desktop_config.json` (npx pulls the server automatically \u2014 nothing to install first):\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\nRestart Claude Desktop. Tools are available immediately.\n\n### Claude Code CLI\n\n```bash\nclaude mcp add flusterduck \\\n --env FLUSTERDUCK_MCP_KEY=fd_mcp_xxxxxxxxxxxx \\\n --env FLUSTERDUCK_SITE_ID=your-site-id \\\n -- npx -y @flusterduck/mcp-server\n```\n\nOr add directly to `~/.claude/settings.json`:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### Cursor\n\n`~/.cursor/mcp.json` (global) or `.cursor/mcp.json` in the project root:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\nReload the Cursor window after saving.\n\n### Windsurf\n\n`~/.codeium/windsurf/mcp_config.json`:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### VS Code (GitHub Copilot)\n\n`.vscode/mcp.json` in the workspace:\n\n```json\n{\n "servers": {\n "flusterduck": {\n "type": "stdio",\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### Remote server\n\nPoint your client at `https://mcp.flusterduck.com/mcp`. There is nothing to copy: the first connection opens a consent page, you sign in with your Flusterduck account, and access is granted through OAuth 2.1 with PKCE. Disconnecting from your MCP client revokes access immediately.\n\n## Keys\n\nThe hosted server never needs a key \u2014 sign-in handles it. MCP keys (`fd_mcp_`, created under Settings > API Keys) are only for the local stdio server.\n\n## MCP or CLI?\n\nBoth surfaces expose the same data; pick by where the agent runs. MCP is right for interactive assistants (Claude Desktop, Cursor, Claude Code sessions) \u2014 persistent connection, tools, and the multi-step prompts below. The [CLI](./cli) is right for CI jobs and shell scripts: `npx flusterduck-cli issues --site <id> --json` for reads, `issue resolve|ignore|reopen|start` to manage issues, and `deploy notify` to record deploys \u2014 every command takes `--json`, so agents can parse the output directly.\n\n**Read-only** (`mcp:read`): all query tools and prompts. Can\'t write anything. Start here.\n\n**Read + write** (`mcp:read` + `manage:write`): adds `update_issue`, `update_alert`, `add_annotation`, `create_alert_rule`, `update_alert_rule`, and `delete_alert_rule`. Needed for triage and annotation workflows.\n\n## Prompts\n\nThe prompts are the highest-value part. They\'re multi-step workflows built into the server. The AI calls several tools in sequence and synthesizes the result into a useful answer. Use prompts for the common cases; use raw tools when you need something specific.\n\n### post_deploy_check\n\nFinds your most recent deploy, compares confusion scores before and after, identifies pages where friction spiked, and returns PASS / WARN / FAIL. Writes an annotation automatically on FAIL.\n\nRun this after every deploy. Takes about 30 seconds.\n\n### triage_open_issues\n\nScans open issues by signal count, moves each to the right status (`triaged`, `in_progress`, or `ignored`), adds a triage note to each, and writes a summary annotation. Requires `manage:write`.\n\nMost useful first thing Monday morning before standup.\n\n### diagnose_page\n\nFive-step diagnosis for a specific page. Returns: current friction score, top signal sources by volume, worst-performing element, likely root cause, and one concrete fix recommendation.\n\n```\npage_path: "/settings/billing"\n```\n\n### investigate_session\n\nFull investigation of a single session: event timeline in order, dominant signal types, matching open issues, and a verdict on whether the session represents a recurring pattern or an outlier.\n\n```\nsession_id: "ses_xxxxxxxxxxxx"\n```\n\n### weekly_summary\n\nA weekly UX friction digest formatted for a PM or eng lead: score trends, newly opened issues, open alerts, revenue at risk, top three recommendations. Under 300 words.\n\nGood to run before Monday standup.\n\n## What a real investigation looks like\n\nYou ask: "Did the deploy yesterday break anything on checkout?"\n\nThe AI runs this automatically:\n\n1. `get_deploys`: finds yesterday\'s deploy and its timestamp\n2. `get_page` with `/checkout`: sees the confusion score jumped from 14 to 38 in the hour after\n3. `get_issues` filtered to `open`: two issues opened within 90 minutes of the deploy, a rage click spike on `#place-order` and elevated form abandonment on the payment step\n4. `get_elements` scoped to `/checkout`: `#place-order` has 52 rage clicks in 24 hours, baseline is 5/day\n5. `diagnose_page`: returns root cause. The button appears inactive during payment processing with no visual indication, causing repeated clicking.\n6. `add_annotation`: "Deploy 2026-06-09: /checkout confusion +171%. #place-order rage clicks 10x baseline. Root cause: missing loading state on payment submit."\n\nThe whole sequence runs in about 30 seconds and leaves a permanent timeline marker.\n\n## Read tools\n\nAll require `mcp:read` scope. Start with `get_site_context`. It gives you the full picture in one call and tells you where to dig next.\n\n**Documentation** \u2014 the full Flusterduck docs are bundled into the server, so your assistant can read them before acting on data.\n\n- `list_docs`: Lists every documentation page (slug, title, group). Start here to see what\'s available.\n- `search_docs`: Full-text search across all docs. Pass `query`; returns matching pages with excerpts.\n- `get_doc`: Returns a full documentation page by `slug` (e.g. `alerts`, `scoring`, `webhooks`, `react`, `mcp`). Read a feature\'s page before acting \u2014 for example, read `alerts` before creating a rule to understand threshold semantics.\n\n**Start here**\n\n- `get_site_context`: Full snapshot of scores, open issues, active alerts, recent deploys, and top recommendations. The right first call for any investigation.\n- `get_scores`: Every tracked page ranked by current confusion score.\n- `get_recommendations`: Prioritized fix list ranked by estimated confusion reduction.\n\n**Issues and alerts**\n\n- `get_issues`: All UX issues. Filter by status: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `regressed`.\n- `get_issue`: Full detail on one issue, including evidence, session links, verification history, and deploy correlation.\n- `get_alerts`: Active and resolved alerts. Filter by `open`, `acknowledged`, or `resolved`.\n- `list_alert_rules`: All configured rules with thresholds, channels, and enabled state. Call this before creating or modifying rules.\n\n**Page deep-dives**\n\n- `get_page`: Score history, active issues, element friction, annotations, and confusion budget for one page. Pass `page: "/checkout"`.\n- `get_elements`: Element-level breakdown showing which buttons, forms, and links generate the most signals. Scope by `page` or pull site-wide.\n- `get_trends`: Confusion score over time. Pass `days` (1-90) and optionally `page`.\n- `compare_pages`: Side-by-side confusion score comparison. Pass `a` and `b` as page paths.\n- `diagnose_journey_friction`: High-friction navigation edges from recent sessions. Filter by `signal_type` or `min_friction_weight`.\n\n**Sessions and raw data**\n\n- `get_session_detail`: Full event timeline for one session in chronological order.\n- `get_heuristics`: The complete signal catalog with all 33 types, scoring weights, and thresholds.\n- `query_raw_rows`: SQL-style access to allowlisted tables (`events`, `signals`, `sessions`, `page_scores`, `score_history`, `ux_issues`, `alerts`, `deploys`).\n- `download_events_csv`: Raw event export as CSV.\n- `explore`: A deterministic, typed query engine over session data \u2014 no natural language, no LLM, built explicitly for agents to answer ad-hoc questions the rest of the surface doesn\'t cover directly. Pick a `window_days` (1-90, default 7), up to 12 AND-ed `filters` over `signal`, `page`, `source`, `confused`, `converted`, `event_type` (ops `is` / `is_not` / `contains`, contains only on `page` and `source`), and exactly one `output`: `{mode: "list"}` to pull matching sessions, or `{mode: "measure", metric, group_by?}` to compute `count`, `avg_pageviews`, `conversion_rate`, `avg_dwell_ms`, or `bounce_rate` as a scalar or, with `group_by` (`page`, `source`, `day`, `signal`, `cohort`), a series. See "Explore via MCP" below for a list and a measure example.\n\n**Deploy correlation**\n\n- `get_deploys`: All deploys Flusterduck knows about, with `confusion_before` and `confusion_after` populated for deploys older than 5 minutes.\n- `get_revenue_impact`: Revenue impact estimates for active friction. Only populated when conversion tracking is wired.\n- `get_flows`: Page-to-page navigation edges from recent sessions.\n\n**Conversion impact**\n\n- `get_conversion_insights`: The confused-vs-calm conversion analysis \u2014 how much less confused sessions convert than calm ones, broken down by page and traffic source, plus ranked narratable insights. Pass `days` (1-90, default 7). Needs a conversion event wired; see [Conversion trigger](./conversion-trigger).\n\n**Org-level** (requires org-scoped key)\n\n- `get_audit_log`: Organization audit log.\n- `get_degradation`: Active and recent backend degradation events. Also available to site-scoped keys, since degradation is operational health rather than tenant data.\n- `get_webhook_deliveries`: Outbound webhook delivery history and failure details.\n\n## Explore via MCP\n\n`explore` is the odd one out on purpose: everything else in this list is a fixed shape (scores, issues, trends), but `explore` is a small closed query language over session data \u2014 a window, up to 12 AND-ed filters, and one output mode. Still fully deterministic and validated against the same allowlists the dashboard uses; there\'s no free-text query and no model in the loop.\n\n**List** \u2014 sessions that rage-clicked on `/pricing` in the last 7 days:\n\n```\nwindow_days: 7\nfilters: [\n { "field": "page", "op": "is", "value": "/pricing" },\n { "field": "signal", "op": "is", "value": "rage_click" }\n]\noutput: { "mode": "list", "limit": 20 }\n```\n\nReturns up to 20 matching sessions, most recent first \u2014 each with its page list, signal counts, source, and confused/converted flags. Use this to cite concrete example sessions as evidence in a diagnosis.\n\n**Measure** \u2014 does confusion actually cost this site conversions?\n\n```\nwindow_days: 30\noutput: { "mode": "measure", "metric": "conversion_rate", "group_by": "cohort" }\n```\n\nReturns conversion rate as a two-point series: the `confused` cohort (sessions with at least one friction signal) versus the `calm` cohort. Swap `group_by` for `page`, `source`, `day`, or `signal` to see the same metric broken down a different way, or drop `group_by` entirely for a single scalar across all matching sessions.\n\n## Write tools\n\nAll require `manage:write` scope.\n\n**`update_issue`**: Change status, add a triage note, or assign an issue.\n\n```\nissue_id: "iss_3a7f2c9d4e1b"\nstatus: "triaged"\nnote: "Confirmed on Safari iOS 17. Disabled-state styling on #place-order doesn\'t communicate that payment is processing."\nassigned_to: "eng-lead"\n```\n\nStatus options: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `ignored`.\n\n**`update_alert`**: Acknowledge, mark investigating, or resolve an alert.\n\n```\nalert_id: "alt_9b5e1f4c2d8a"\nstatus: "resolved"\nresolved_reason: "Deploy 2026-06-09 added a spinner and disabled state to the payment submit button."\n```\n\n**`add_annotation`**: Write a timeline marker visible to the whole team.\n\n```\nmessage: "Redesigned billing flow launched. Monitoring confusion score on /settings/billing."\n```\n\n**`create_alert_rule`**: Create a new alert rule.\n\n```\nname: "Checkout rage click spike"\ntrigger_type: "spike"\nthreshold: 25\ncooldown_minutes: 60\nchannels: ["email", "slack"]\npage_pattern: "/checkout*"\n```\n\n**`update_alert_rule`**: Update an existing rule. Use `enabled: false` to silence it temporarily rather than deleting.\n\n**`delete_alert_rule`**: Permanently remove a rule. Can\'t be undone.\n\n## Questions that work well\n\n- What\'s broken on checkout right now?\n- Did the last deploy make things worse?\n- Which page has the most dead clicks this week?\n- Triage the open issues and tell me what to fix first.\n- Write a friction summary for the PM standup.\n- Which sessions show rage clicks on the upgrade button?\n- How does this week\'s confusion score compare to last week?\n- Create a spike alert for the pricing page and notify Slack.\n- What\'s the revenue impact of the current open issues?\n- Which elements on `/onboarding` are generating the most friction?\n'
|
|
462
539
|
},
|
|
463
540
|
{
|
|
464
541
|
"slug": "api",
|
|
@@ -488,13 +565,6 @@ All plans include a 7-day free trial. No credit card required.
|
|
|
488
565
|
"group": "Integrations",
|
|
489
566
|
"content": "# PostHog trigger layer\n\nPostHog bills by event volume. A typical session emits 60-200 events, and 95-98% of those sessions have zero friction. You're paying full price for data that confirms everything worked fine.\n\nThe trigger layer wraps `posthog.capture` and cuts ingestion 70-90% while keeping 100% of the events that explain why a user got frustrated. It ships in the `flusterduck` SDK as `initPostHogTrigger`.\n\n## Why per-event sampling fails\n\nDropping 90% of events with `Math.random()` breaks two things.\n\n**Funnels.** PostHog funnels need every step from a single user. Per-event sampling keeps step 1 for one user and step 3 for another. Funnel numbers become garbage.\n\n**Context.** The events that explain a rage click happen *before* the rage click. If the drop decision happens at capture time, the lead-up is gone by the time you know you needed it.\n\n## How it works\n\n### Per-user deterministic sampling\n\nThe keep/drop decision hashes the PostHog `distinct_id` with FNV-1a, so a user is either fully in or fully out of the sampled cohort. Kept users keep every event. Funnels, paths, and session groupings stay internally coherent. Aggregate counts scale by `1 / sampleRate`, the standard sampled-cohort model.\n\n### Retroactive capture\n\nDropped events aren't discarded. They sit in a rolling buffer (default: last 30 seconds, max 50 events). When Flusterduck detects a friction signal (rage click, dead click, error encounter, form validation loop, thrash cursor), the buffer flushes to PostHog. Every recovered event carries:\n\n- `fd_recovered: true`\n- `fd_recovered_reason`: the signal that triggered recovery (e.g. `\"rage_click\"`)\n- `fd_original_ts`: the original capture timestamp\n\nYou pay for the lead-up events only when a user actually got frustrated. Full context on the 2% of sessions that matter, 10% sampling on the 98% that don't.\n\n### Boost window\n\nAfter a critical signal fires, everything is captured for 10 seconds (configurable) so the aftermath is preserved alongside the lead-up. Boosted events carry `fd_boosted: true`.\n\n### Priority tiers\n\n| Tier | Events | Behavior |\n|---|---|---|\n| P0 (critical) | `$rageclick`, `$dead_click`, `$exception`, `$identify`, `$create_alias`, `$groupidentify`, `$survey_sent`, `$survey_dismissed`, `$feature_flag_called` | Always captured |\n| P1 (conversions) | Your `conversionEvents` list, plus pattern-matched names: signup, checkout, purchase, payment, upgrade, trial, order completed | Always captured |\n| P2 (general) | Pageviews, custom events, everything else | Sampled at `baselineSampleRate` (default 10%) |\n| P3 (noise) | Scroll and heatmap events (`$$heatmap*`, `$scroll*`) | Sampled at `scrollSampleRate` (default 1%) |\n\nP0 and P1 events always pass. P2 and P3 events are sampled unless a boost window is active or the buffer flushes them retroactively.\n\n### Signal annotation\n\nEvery captured event carries the user's recent Flusterduck signals as properties:\n\n- `fd_recent_signals`: array of signal names from the last 60 seconds\n- `fd_signal_count`: how many signals fired recently\n\nYou can segment any PostHog insight by frustration without leaving PostHog. Filter `fd_signal_count > 0` to see only frustrated-session data.\n\n## Setup\n\nCall `initPostHogTrigger` after `init`. It waits for PostHog to load on its own, so order doesn't matter and you don't need to coordinate script loading.\n\n```typescript\nimport { init, initPostHogTrigger } from 'flusterduck';\n\ninit({ key: 'fd_pub_...' });\n\ninitPostHogTrigger({\n conversionEvents: ['plan_changed', 'seat_added'],\n});\n```\n\nThat's the whole integration. Two lines after your existing Flusterduck init.\n\n### With React\n\n```typescript\nimport { useEffect } from 'react';\nimport { initPostHogTrigger, destroyPostHogTrigger } from 'flusterduck';\n\nfunction App() {\n useEffect(() => {\n initPostHogTrigger({\n conversionEvents: ['plan_changed'],\n });\n return () => destroyPostHogTrigger();\n }, []);\n\n return <>{/* your app */}</>;\n}\n```\n\n### With Next.js\n\nIf you're using `@flusterduck/next`, call `initPostHogTrigger` in a client component or in a `useEffect` in your root layout. The `FlusterduckScript` component handles the core SDK. The trigger layer is a separate call because not every Flusterduck customer uses PostHog.\n\n```typescript\n'use client';\n\nimport { useEffect } from 'react';\nimport { initPostHogTrigger, destroyPostHogTrigger } from 'flusterduck';\n\nexport function PostHogTrigger() {\n useEffect(() => {\n initPostHogTrigger();\n return () => destroyPostHogTrigger();\n }, []);\n return null;\n}\n```\n\nDrop `<PostHogTrigger />` in your root layout alongside `<FlusterduckScript />`.\n\n## Options\n\n| Option | Type | Default | What it does |\n|---|---|---|---|\n| `baselineSampleRate` | `number` | `0.1` | Keep rate for P2 events (general pageviews, custom events). Range: 0 to 1. |\n| `scrollSampleRate` | `number` | `0.01` | Keep rate for P3 events (scroll and heatmap noise). Range: 0 to 1. |\n| `conversionEvents` | `string[]` | `[]` | Event names added to the always-keep P1 tier, on top of the built-in conversion pattern match. |\n| `criticalSignals` | `string[]` | `['rage_click', 'rage_click_repeat_target', 'dead_click', 'error_encounter', 'form_validation_loop', 'thrash_cursor']` | Flusterduck signals that trigger retroactive buffer flush and the boost window. |\n| `bufferWindowMs` | `number` | `30000` | How far back retroactive capture reaches, in milliseconds. |\n| `bufferMaxEvents` | `number` | `50` | Max events held in the retroactive buffer. |\n| `boostWindowMs` | `number` | `10000` | How long everything is captured after a critical signal fires, in milliseconds. |\n\n### Tuning the sample rates\n\nThe defaults (10% baseline, 1% scroll) work well for most sites. If you want to tune:\n\n**Lower `baselineSampleRate`** to cut cost further. At `0.05` (5%), you're keeping conversions, critical events, and friction context but paying half as much for clean-session data. Funnel analysis still works because sampling is per-user, not per-event.\n\n**Raise `scrollSampleRate`** if you rely on PostHog heatmaps. At `0.05` you'll get 5x more heatmap data while still cutting 95% of scroll noise.\n\n**Add to `conversionEvents`** any custom event name that PostHog funnels depend on. The built-in pattern catches common names (signup, checkout, purchase, upgrade, trial, order placed), but your product-specific events like `workspace_created` or `first_query_run` need to be listed explicitly.\n\n## Cost math\n\nA site doing 100,000 sessions/month with an average of 120 PostHog events per session:\n\n| Scenario | Monthly events | Cost at $0.00045/event |\n|---|---|---|\n| No trigger layer | 12,000,000 | $5,400 |\n| Trigger layer, defaults | 1,800,000 | $810 |\n| Trigger layer, 5% baseline | 1,200,000 | $540 |\n\nThe savings scale linearly with session volume. Heatmap-heavy sites see larger cuts because P3 dominates their event volume.\n\nThe 1.8M figure comes from: 100% of P0/P1 events (roughly 5% of total), 10% of P2 events, 1% of P3 events, plus retroactive flushes on the ~2-5% of sessions with friction signals.\n\n## PostHog properties reference\n\nProperties the trigger layer adds to captured events:\n\n| Property | Type | When present |\n|---|---|---|\n| `fd_recent_signals` | `string[]` | Any event captured while recent Flusterduck signals exist (last 60 seconds) |\n| `fd_signal_count` | `number` | Same as above |\n| `fd_recovered` | `boolean` | Events flushed retroactively from the buffer after a critical signal |\n| `fd_recovered_reason` | `string` | The Flusterduck signal name that caused the flush (e.g. `\"rage_click\"`) |\n| `fd_original_ts` | `number` | Original capture timestamp (Unix ms) on recovered events |\n| `fd_boosted` | `boolean` | Events captured during the boost window after a critical signal |\n\n### Using these in PostHog\n\n**Segment by frustration.** Create a cohort where `fd_signal_count > 0`. Apply it to any insight to see only frustrated-session behavior.\n\n**Find recovered context.** Filter events where `fd_recovered = true` to see the lead-up to friction moments. The `fd_recovered_reason` tells you which signal triggered the recovery.\n\n**Measure boost windows.** Filter `fd_boosted = true` to see what users did immediately after a friction moment. Useful for understanding whether frustrated users retry, leave, or contact support.\n\n## Cleanup\n\nCall `destroyPostHogTrigger()` to remove the wrapper and restore the original `posthog.capture`. The SDK checks that its wrapper is still installed before restoring, so if PostHog re-initialized in the meantime, it won't overwrite the new capture function.\n\n```typescript\nimport { destroyPostHogTrigger } from 'flusterduck';\n\ndestroyPostHogTrigger();\n```\n\n## How it handles the PostHog stub\n\nThe posthog-js snippet installs a queueing stub on `window.posthog` before the real library loads. The real library then replaces the stub's methods. Wrapping the stub would be silently undone.\n\nThe trigger layer polls every 500ms until `posthog.__loaded` is `true`, then wraps the real `capture`. Polling stops after 30 seconds if PostHog never loads. The page is never affected: no errors thrown, no visible behavior change, no console noise.\n\n## What this doesn't do\n\nIt doesn't replace PostHog. It assumes you keep PostHog and want it cheaper.\n\nIt's not lossless for aggregate counts. Sampled tiers undercount by design. Multiply by `1 / sampleRate` or segment on the kept cohort.\n\nIt does nothing without PostHog on the page. No `window.posthog`, no behavior, no errors.\n"
|
|
490
567
|
},
|
|
491
|
-
{
|
|
492
|
-
"slug": "trainai-bridge",
|
|
493
|
-
"title": "TrainAI bridge",
|
|
494
|
-
"description": "Annotate TrainAI traces with friction signals so you can correlate UX problems with AI interactions.",
|
|
495
|
-
"group": "Integrations",
|
|
496
|
-
"content": "# TrainAI bridge\n\nYour AI features generate traces in TrainAI. Your users generate friction signals in Flusterduck. The `@flusterduck/trainai-bridge` package connects the two: when a friction signal fires in the browser, the bridge annotates the active TrainAI trace with the signal type, element, weight, and timing.\n\nThe result is friction data attached directly to the AI interaction that caused it. A rage click on a generation button, form abandonment mid-prompt, a dead click on a suggested action. Each one lands on the trace that was running when it happened.\n\n## Install\n\n```bash\nnpm install @flusterduck/trainai-bridge\n```\n\nThe bridge is a thin layer between Flusterduck's `onSignal` callback and TrainAI's trace annotation API. It has peer dependencies on both `flusterduck` and `@trainai/sdk`.\n\n## Quick setup\n\n```ts\nimport { init, onSignal } from 'flusterduck'\nimport { createTrainAIBridge } from '@flusterduck/trainai-bridge'\n\nconst bridge = createTrainAIBridge({\n // Your TrainAI API key (client-safe publishable key)\n apiKey: process.env.NEXT_PUBLIC_TRAINAI_KEY!,\n // Optional: only bridge certain signal types\n signals: ['rage_click', 'dead_click', 'form_abandonment', 'error_recovery_loop'],\n // Optional: minimum weight to annotate (0-100, default 0)\n minWeight: 10,\n})\n\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n onSignal: bridge.handler,\n})\n```\n\nThat's it. Every friction signal that passes your filters is sent to TrainAI as a trace annotation.\n\nIf you're already using `onSignal` for something else, compose them:\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n onSignal(signal) {\n bridge.handler(signal)\n yourOtherHandler(signal)\n },\n})\n```\n\nOr subscribe after init:\n\n```ts\nimport { onSignal } from 'flusterduck'\n\nonSignal(bridge.handler)\n```\n\n## How it works\n\nWhen Flusterduck detects a friction signal, the bridge:\n\n1. Checks the signal against your `signals` filter (if set) and `minWeight` threshold.\n2. Looks up the currently active TrainAI trace via `TrainAI.getCurrentTrace()`.\n3. If a trace is active, annotates it with a `flusterduck.signal` event containing the signal name, element selector, weight, and timestamp.\n4. If no trace is active, the signal is silently dropped. No error, no queue.\n\nThe bridge never buffers or retries. Signals that fire outside an active trace are ignored because there's no trace to attach them to. This keeps memory usage at zero and avoids stale data.\n\n## Annotation format\n\nEach annotation lands on the TrainAI trace as a structured event:\n\n```json\n{\n \"type\": \"flusterduck.signal\",\n \"name\": \"rage_click\",\n \"element\": \"button#generate-response\",\n \"weight\": 25,\n \"timestamp\": 1718900425000,\n \"meta\": {}\n}\n```\n\n| Field | Type | Description |\n|---|---|---|\n| `type` | `string` | Always `flusterduck.signal` |\n| `name` | `string` | The signal type: `rage_click`, `dead_click`, `form_abandonment`, etc. |\n| `element` | `string` | CSS selector of the element involved. Empty string if not element-bound. |\n| `weight` | `number` | Severity contribution, 0 to 100. Higher means more friction. |\n| `timestamp` | `number` | Unix milliseconds when the signal fired. |\n| `meta` | `object` | Detector metadata (counts, distances, timings). Empty for built-in signals. |\n\nThe `meta` field carries the same data as `EmittedSignal.meta` from the Flusterduck SDK. Built-in detectors keep it empty. If you emit custom signals with `signal()`, whatever metadata you pass shows up here.\n\n## Configuration\n\n### `createTrainAIBridge(options)`\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `apiKey` | `string` | required | Your TrainAI publishable API key. |\n| `signals` | `string[]` | all signals | Allowlist of signal types to bridge. Signals not in this list are ignored. |\n| `minWeight` | `number` | `0` | Minimum weight threshold. Signals below this weight are skipped. |\n| `traceProvider` | `object` | TrainAI global | Custom trace provider with a `getCurrentTrace()` method. For testing or custom setups. |\n| `annotationType` | `string` | `flusterduck.signal` | Override the annotation type string if your TrainAI pipeline expects a different event name. |\n| `onError` | `(err: Error) => void` | silent | Called if the TrainAI annotation call fails. By default, errors are swallowed so they can't break your app. |\n\n### Filtering by signal type\n\nPass the signal names you care about. The full list is in the [signals reference](/signals).\n\n```ts\nconst bridge = createTrainAIBridge({\n apiKey: process.env.NEXT_PUBLIC_TRAINAI_KEY!,\n // Only care about high-severity interaction signals\n signals: [\n 'rage_click',\n 'dead_click',\n 'error_recovery_loop',\n 'silent_failure_retry',\n 'form_abandonment',\n 'dead_end_submit',\n ],\n})\n```\n\n### Filtering by weight\n\nWeight filtering is useful when you want to ignore low-severity signals that would create noise in your traces. Tier 1 signals (rage clicks, dead clicks, form abandonment) have weights of 12-38. Tier 3 signals (jerky scrolling, passive drift) are 6-15.\n\n```ts\nconst bridge = createTrainAIBridge({\n apiKey: process.env.NEXT_PUBLIC_TRAINAI_KEY!,\n minWeight: 15, // Skip anything below weight 15\n})\n```\n\n## React setup\n\nIf you're using `@flusterduck/react`, pass the bridge handler through the provider:\n\n```tsx\nimport { FlusterduckProvider } from '@flusterduck/react'\nimport { createTrainAIBridge } from '@flusterduck/trainai-bridge'\n\nconst bridge = createTrainAIBridge({\n apiKey: process.env.NEXT_PUBLIC_TRAINAI_KEY!,\n})\n\nfunction App() {\n return (\n <FlusterduckProvider\n publishableKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!}\n onSignal={bridge.handler}\n >\n {/* your app */}\n </FlusterduckProvider>\n )\n}\n```\n\n## Next.js setup\n\nWith `@flusterduck/next`, use the `onSignal` prop on the script component, then subscribe client-side:\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html>\n <body>\n {children}\n <FlusterduckScript publishableKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!} />\n </body>\n </html>\n )\n}\n```\n\n```tsx\n// components/trainai-bridge.tsx\n'use client'\n\nimport { useEffect } from 'react'\nimport { onSignal } from 'flusterduck'\nimport { createTrainAIBridge } from '@flusterduck/trainai-bridge'\n\nconst bridge = createTrainAIBridge({\n apiKey: process.env.NEXT_PUBLIC_TRAINAI_KEY!,\n})\n\nexport function TrainAIBridgeInit() {\n useEffect(() => {\n const off = onSignal(bridge.handler)\n return off\n }, [])\n return null\n}\n```\n\nDrop `<TrainAIBridgeInit />` anywhere in your component tree. It subscribes once on mount and cleans up on unmount.\n\n## What to look for in TrainAI\n\nOnce the bridge is running, you'll see `flusterduck.signal` annotations on your traces. Filter traces in TrainAI by annotation type to find AI interactions that caused user frustration.\n\nPatterns worth investigating:\n\n- **Rage clicks during generation**: the user clicked a generate button repeatedly because nothing happened. Check whether the trace shows a slow model call or a dropped response.\n- **Dead clicks on AI suggestions**: the model returned a suggestion the user tried to interact with, but it wasn't wired up. The trace shows what was generated; the annotation shows what the user tried to do with it.\n- **Form abandonment on AI-powered forms**: the user started filling in an AI-assisted form and gave up. The trace shows what the AI pre-filled or suggested; the annotation shows where the user stopped.\n- **Error recovery loops**: the user submitted the same form repeatedly after AI validation kept rejecting their input. The trace shows the validation logic; the annotations show the submission cadence.\n\n## Custom signals\n\nIf you emit custom signals with `signal()`, they flow through the bridge too:\n\n```ts\nimport { signal } from 'flusterduck'\n\n// Your app detects a user retrying an AI generation\nsignal('ai_generation_retry', {\n element: '#generate-btn',\n metadata: { attempt: 3, model: 'claude-sonnet' },\n weight: 20,\n})\n```\n\nThis shows up on the active TrainAI trace as a `flusterduck.signal` annotation with `name: \"ai_generation_retry\"` and the metadata you passed.\n\n## Destroying the bridge\n\nThe bridge has no internal state, so there's nothing to clean up. If you subscribed via `onSignal`, the unsubscribe function handles teardown. If you passed the handler through `init({ onSignal })`, it's torn down when the SDK's `destroy()` runs.\n\n```ts\n// If you used onSignal:\nconst off = onSignal(bridge.handler)\noff() // done\n\n// If you used init({ onSignal: bridge.handler }):\nimport { destroy } from 'flusterduck'\ndestroy() // tears down everything including signal subscriptions\n```\n\n## Privacy\n\nThe bridge sends the same data Flusterduck collects: signal type, CSS selector, weight, and timing. No form values, no text content, no PII. The CSS selector identifies the element structurally (e.g. `button#generate-response`, `form.checkout [name=\"email\"]`) but never captures what the user typed into it.\n\nIf you emit custom signals with metadata, whatever you put in `metadata` is forwarded to TrainAI. Keep PII out of your custom signal metadata the same way you would for any client-side telemetry.\n"
|
|
497
|
-
},
|
|
498
568
|
{
|
|
499
569
|
"slug": "rest-api",
|
|
500
570
|
"title": "REST API",
|
|
@@ -521,21 +591,21 @@ All plans include a 7-day free trial. No credit card required.
|
|
|
521
591
|
"title": "Security",
|
|
522
592
|
"description": "Collection rules, data access boundaries, hashing, input limits, and the OWASP checklist.",
|
|
523
593
|
"group": "Privacy & security",
|
|
524
|
-
"content": "# Security\n\nFlusterduck collects behavioral signals, not private content. This page covers the full security model: how authentication works, what the database enforces, how keys are hashed, where rate limits sit, and what input validation looks like at the edge. If you're running a security review, this is your reference.\n\n## What we don't collect\n\n- No session replay\n- No DOM recording\n- No form values or text content\n- No passwords, tokens, or credentials\n- No raw IP addresses (hashed at the edge before storage)\n- No direct browser access to analytics tables\n\nThe SDK captures behavioral patterns only: clicks, scroll depth, cursor movement, timing between interactions. It never reads input values or DOM text.\n\n## Authentication\n\nFour distinct authentication paths exist, each scoped to the surface it protects.\n\n### Publishable keys (`fd_pub_`)\n\nThe browser SDK authenticates with a publishable key included in each event batch. The `ingest` edge function validates the key by computing its HMAC-SHA256 hash and looking up the matching `site_environments` row. If the site is inactive, the org's billing has lapsed, or the trial has expired, ingest rejects the batch.\n\nPublishable keys can only write events. They can't read scores, issues, or any other data.\n\n### User JWTs\n\nThe dashboard and `manage` edge function authenticate via Supabase Auth JWTs. Middleware calls `supabase.auth.getUser()` (not `getSession()`) to validate the token server-side on every request. `getUser()` hits the auth server, so stolen session cookies with expired JWTs are caught.\n\nAfter JWT validation, every route checks org membership through `requireOrgRole()`. Admin-only operations (creating sites, managing keys, inviting members) require the `owner` or `admin` role.\n\n### Secret keys (`fd_sec_`)\n\nServer-side integrations use secret keys with the `query` and `manage` edge functions. Each key carries a set of scopes (`query:read`, `manage:write`, `webhook:write`), a per-key rate limit, and an optional IP allowlist.\n\nAuthentication works the same way as publishable keys: HMAC-SHA256 hash lookup against the `api_keys` table. If the key is revoked, expired, or missing the required scope, the request fails. If an IP allowlist is configured and the caller's IP doesn't match (supports CIDR notation), the request fails with `ip_not_allowed`.\n\n### MCP keys (`fd_mcp_`)\n\nMCP keys work like secret keys but carry the `mcp:read` scope. They're issued by the `mcp-auth-bridge` edge function and used by the Cloudflare MCP Worker to give AI assistants read access to scores and issues.\n\n### How `authenticateQuery` picks the path\n\nThe `query` edge function's auth dispatcher checks the Bearer token prefix:\n\n```ts\nif (token.startsWith('fd_sec_')) return authenticateSecretKey(req, db, 'query:read');\nif (token.startsWith('fd_mcp_')) return authenticateSecretKey(req, db, 'mcp:read');\nreturn authenticateUser(req, db);\n```\n\n`manage` follows the same pattern but requires the `manage:write` scope for API keys.\n\n## Authorization\n\nAuthentication proves identity. Authorization proves access.\n\n- Every `query` route requires a `site_id` parameter. `ensureSiteAccess()` loads the site's `org_id`, then verifies the caller belongs to that org (for JWTs) or owns that org (for API keys). Site-scoped API keys can only access their specific site.\n- Every `manage` route calls `requireManageAccess()`, which checks org membership with the appropriate role. Viewers can't modify anything. Members can update issues but can't create sites or keys.\n- Org creation is capped at 5 per user. Admin count is capped at 10 per org.\n- Users can't change their own role or remove themselves from an org.\n- The `audit_logs` table records who did what, with IP hashes and user-agent hashes for forensics.\n\n## Rate limits\n\nEvery edge function enforces rate limits through an atomic Postgres RPC (`consume_rate_limit_bucket`) that increments a counter within a time-bucketed window. When the limit is hit, the function returns HTTP 429 with a `reset_at` timestamp.\n\n| Surface | Limit | Window |\n|---------|-------|--------|\n| `ingest` | 10,000 requests | 60 seconds, per environment |\n| `query` | 100 requests (or per-key custom RPM) | 60 seconds, per org + caller |\n| `manage` | 30 requests | 60 seconds, per actor |\n| `waitlist` | 5 requests | 15 minutes, per IP |\n| `webhooks` | 60 requests | 60 seconds |\n\nAPI keys can have a custom `rate_limit_rpm` value. The `query` function uses that value instead of the default 100 when the caller authenticates with a key.\n\n## Cryptography\n\n### API key hashing\n\nKeys are never stored in plaintext. When a key is created, its HMAC-SHA256 hash is computed using the `KEY_HASH_SALT` environment secret and stored in the `key_hash` column. On every request, the same HMAC is recomputed from the raw key and matched against the stored hash.\n\n```ts\nexport async function hashAPIKey(key: string): Promise<string> {\n return hmacSha256Hex(env('KEY_HASH_SALT'), key);\n}\n```\n\n`KEY_HASH_SALT` is a required secret with no hardcoded fallback. If it's missing, the function throws immediately.\n\n### IP address hashing\n\nIP addresses are hashed with HMAC-SHA256 using `IP_HASH_SALT` and truncated to 32 hex characters before storage. The original IP is never written to any table.\n\n```ts\nexport async function hashIP(ip: string | null): Promise<string | null> {\n if (!ip) return null;\n return (await hmacSha256Hex(env('IP_HASH_SALT'), ip)).slice(0, 32);\n}\n```\n\n### Timing-safe comparison\n\nAll key and signature comparisons use constant-time XOR to prevent timing attacks:\n\n```ts\nexport function safeEqual(a: string, b: string): boolean {\n const max = Math.max(a.length, b.length);\n let diff = a.length ^ b.length;\n for (let index = 0; index < max; index += 1) {\n diff |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);\n }\n return diff === 0;\n}\n```\n\nThis function is used for webhook signature verification, Slack signature verification, and API key comparison. Length differences are caught by the initial XOR without short-circuiting.\n\n### Webhook signatures\n\nOutbound webhooks are signed with HMAC-SHA256. The signature covers a timestamp concatenated with the payload body (`{timestamp}.{payload}`), providing replay protection. Verification rejects signatures older than 300 seconds.\n\n### Slack signatures\n\nSlack requests are verified using the `SLACK_SIGNING_SECRET` with Slack's `v0:` signature format and a 5-minute timestamp tolerance window.\n\n### Webhook secret encryption\n\nWebhook endpoint secrets are encrypted at rest using AES-256-GCM. The encryption key is derived from a dedicated environment secret via SHA-256. Each encrypted value carries its own random 12-byte IV.\n\n## Row-level security\n\nRLS is enabled on every table in the public schema. But the policies don't grant access to `anon` or `authenticated` roles for product data. Instead, a blanket revocation prevents any direct database queries from browser clients.\n\n### The REVOKE ALL strategy\n\nThe `deny_direct_database_policies` migration runs these statements:\n\n```sql\nrevoke all on all tables in schema public from anon;\nrevoke all on all tables in schema public from authenticated;\nrevoke all on all sequences in schema public from anon;\nrevoke all on all sequences in schema public from authenticated;\nrevoke all on all routines in schema public from public;\nrevoke all on all routines in schema public from anon;\nrevoke all on all routines in schema public from authenticated;\ngrant execute on all routines in schema public to service_role;\n```\n\nThen `ALTER DEFAULT PRIVILEGES` ensures future tables and functions inherit the same restrictions. Every table gets a deny-all RLS policy for `anon` and `authenticated`:\n\n```sql\ncreate policy deny_client_access_<table> on public.<table>\n for all to anon, authenticated using (false) with check (false);\n```\n\nEdge functions use the `service_role` key, which bypasses RLS. Browser clients use the `anon` key for Supabase Auth calls only. They can't touch product data.\n\n### Private tables\n\n21 tables are explicitly listed in the hardened migration as having deny-all policies regardless of any other policy: `alert_delivery_queue`, `api_keys`, `audit_logs`, `degradation_events`, `deploy_correlations`, `integration_connections`, `issue_signal_evidence`, `issue_verifications`, `mcp_oauth_clients`, `mcp_oauth_grants`, `mcp_oauth_tokens`, `processed_stripe_events`, `rate_limit_buckets`, `scheduled_tasks`, `sdk_configs`, `site_environments`, `slack_messages`, `ux_issues`, `waitlist`, `webhook_deliveries`, `webhook_endpoints`.\n\n### Security definer functions\n\nFive functions in the `private` schema run with `SECURITY DEFINER` and `SET search_path = public` to prevent search-path injection:\n\n| Function | Purpose |\n|----------|---------|\n| `private.user_org_ids()` | Returns array of org IDs the current auth user belongs to |\n| `private.user_site_ids()` | Returns array of site IDs across the user's orgs |\n| `private.user_role(org_id)` | Returns the user's role in a specific org |\n| `private.has_org_role(org_id, roles[])` | Checks if the user has one of the specified roles |\n| `private.can_join_org(org_id, user_id)` | Checks if a user can be added to an org |\n\nThese functions back the RLS policies on org-scoped tables. The `organizations` table uses `id = any(private.user_org_ids())` for SELECT. The 26 org-scoped data tables (sites, events, signals, page_scores, alerts, etc.) use `org_id = any(private.user_org_ids())`. Write policies on admin-gated tables also check `private.has_org_role(org_id, array['owner', 'admin'])`.\n\n### Safe views\n\nTwo views hide sensitive columns from clients:\n\n- `api_keys_safe` exposes key metadata (prefix, scopes, rate limit, last used, expiry) but omits `key_hash`.\n- `sites_safe` exposes site metadata (name, URL, status) but omits `secret_key_hash` and internal fields.\n\nBoth views use `security_invoker = true`, so the caller's RLS policies still apply.\n\n## Input validation\n\n### Request body limits\n\nAll edge functions cap request bodies at 64KB (65,536 bytes). Gzip payloads are decompressed server-side and checked against the same limit after decompression. Requests that claim gzip encoding but don't contain valid gzip data get a 400 error.\n\n### String sanitization\n\nUser-controlled strings pass through `sanitize()`, which strips `< > \" ' & `` ` characters and truncates to 500 characters:\n\n```ts\nexport function sanitize(value: unknown, maxLength = 500): string {\n if (typeof value !== 'string') return '';\n return value.replace(/[<>\"'&`]/g, '').slice(0, maxLength).trim();\n}\n```\n\n### UUID validation\n\nEvery UUID parameter is validated against a strict regex before any database query:\n\n```ts\n/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n```\n\n### Enum allowlists\n\nThe `manage` function validates these values against hardcoded `Set` objects:\n\n- `trigger_type`: spike, anomaly, new_page, trend, co_occurrence, positive, budget\n- `channels`: email, slack, webhook, mcp, pagerduty\n- `member_roles`: owner, admin, member, viewer\n- `dom_modes`: off, metadata, snapshot\n- `issue_statuses`: open, triaged, in_progress, verified, resolved, ignored\n- `scopes`: ingest:write, query:read, manage:write, mcp:read, webhook:write\n\n### Numeric clamping\n\n`intParam()` clamps numeric query parameters to a safe range with a fallback default. Thresholds are clamped to 0-1000, cooldown minutes to 1-1440.\n\n## Content Security Policy\n\nThe web app generates a per-request CSP with a cryptographic nonce. Key directives:\n\n- `script-src 'self' 'nonce-{random}' 'strict-dynamic'` blocks inline scripts without the nonce. `strict-dynamic` lets nonced scripts load their dependencies.\n- `connect-src 'self' https://rhwhnkrqjlzyzcdhvyky.supabase.co` restricts network calls to same-origin (the `/api/v1` proxy) and the Supabase auth endpoint. No wildcards.\n- `frame-ancestors 'none'` and `frame-src 'none'` prevent clickjacking.\n- `object-src 'none'` and `worker-src 'none'` block plugin and worker execution.\n- `upgrade-insecure-requests` is set in production.\n\nThe nonce is generated from 16 cryptographically random bytes per request. No nonce reuse across responses.\n\n## Audit logging\n\nThe `audit_logs` table records management actions with:\n\n- Actor identity (user ID or API key ID)\n- Action name and target (e.g., `create` on `alert_rule`)\n- Hashed IP address and hashed user-agent\n- Arbitrary metadata for the specific operation\n\nIP addresses in audit logs are hashed with the same `hashIP()` function used everywhere else. User agents are SHA-256 hashed and truncated to 64 hex characters.\n\n## Browser SDK hygiene\n\nUse attributes or stable selectors for signal context. Don't send private user data in metadata.\n\n```ts\nsignal('dead_click', {\n page: '/checkout',\n selector: '[data-action=\"continue\"]',\n})\n```\n\nDon't do this:\n\n```ts\ntrack('checkout_note', {\n email: user.email,\n message: form.message,\n})\n```\n\nThe SDK scrubs JSON payloads at the edge with `scrubJson()` before storage. But the safest approach is to never send sensitive data in the first place.\n\n## Network-level controls\n\n- API keys support IP allowlists with CIDR notation. Requests from IPs outside the allowlist get a 403.\n- Webhook endpoint URLs are validated by `assertPublicWebhookUrl()` to prevent SSRF against internal networks.\n- The web app's middleware distinguishes browser navigations from API fetches using `Sec-Fetch-Dest`. Unauthenticated browser requests redirect to `/login`. Unauthenticated API fetches get a 401 JSON response.\n- Auth callback validates redirect targets against a `SAFE_REDIRECTS` allowlist to prevent open redirects.\n"
|
|
594
|
+
"content": "# Security\n\nFlusterduck collects behavioral signals, not private content. This page covers the security model: what leaves the browser, how authentication and authorization work, how keys are protected, where rate limits sit, and what the database enforces. If you're running a security review, this is your reference \u2014 and if you have a question it doesn't answer, ask us at support@flusterduck.com.\n\n## What leaves the browser \u2014 and what never does\n\nThe SDK captures behavioral patterns: clicks, scroll depth, cursor movement, timing between interactions, and the **visible label** of an element a user interacted with (button text, a heading, an aria-label) so traces read in plain language \u2014 \"Rage click on *Apply coupon*\" instead of a bare CSS selector. Labels are PII-redacted **in the browser before anything is sent**: email addresses, phone numbers, card-length digit runs, and web addresses are replaced with `[redacted]` in place. A second, server-side redaction pass runs at ingest as a backstop.\n\nWhat is never collected, by architecture rather than configuration:\n\n- No session replay or screen recording\n- No DOM recording or page-content capture from visitors' sessions\n- No form values, no user-entered text, no keystrokes\n- No passwords, tokens, or credentials\n- No raw IP addresses \u2014 hashed at the edge before storage, originals never written\n\n## Authentication\n\nFour distinct authentication paths exist, each scoped to the surface it protects.\n\n### Publishable keys (`fd_pub_`)\n\nThe browser SDK authenticates with a publishable key included in each event batch. The ingest endpoint validates the key against a salted HMAC-SHA256 hash \u2014 raw keys are never stored \u2014 and rejects batches for inactive sites, lapsed billing, or expired trials. Publishable keys can only write events. They can never read scores, issues, or any other data.\n\n### User JWTs\n\nThe dashboard authenticates via Supabase Auth JWTs, validated server-side against the auth server on every request \u2014 so a stolen session cookie with an expired token is caught rather than trusted. After validation, every route independently checks org membership and role; admin-only operations require the `owner` or `admin` role.\n\n### Secret keys (`fd_sec_`)\n\nServer-side integrations use secret keys with the read and write APIs. Each key carries explicit scopes (`query:read`, `manage:write`, `webhook:write`), a per-key rate limit, and an optional IP allowlist with CIDR support. Revoked, expired, mis-scoped, or wrong-IP requests fail closed.\n\n### MCP keys (`fd_mcp_`)\n\nMCP keys work like secret keys but carry only the `mcp:read` scope. They give AI assistants read access to scores and issues through the hosted MCP server \u2014 never write access.\n\n## Authorization\n\nAuthentication proves identity; authorization proves access. Every read route verifies that the caller's organization owns the requested site before returning a byte. Every write route checks org membership with the appropriate role \u2014 viewers can't modify anything; members can update issues but can't create sites or keys. Users can't change their own role or remove themselves from an org. Org creation and admin counts are capped. Management actions are recorded in an audit log with hashed IP and user-agent for forensics.\n\n## Rate limits\n\nEvery endpoint enforces rate limits through an atomic, database-backed counter. When a limit is hit, the API returns HTTP 429 with a `reset_at` timestamp.\n\n| Surface | Limit | Window |\n|---------|-------|--------|\n| Event ingestion | 10,000 requests | 60 seconds, per environment |\n| Read API | 100 requests (or per-key custom RPM) | 60 seconds, per org + caller |\n| Write API | 30 requests | 60 seconds, per actor |\n| Waitlist | 5 requests | 15 minutes, per IP |\n| Webhook delivery | 60 requests | 60 seconds |\n\nAPI keys can carry a custom per-key limit, which replaces the default on the read API.\n\n## Cryptography\n\n- **API keys** are never stored in plaintext. Keys are hashed with HMAC-SHA256 using a server-side salt that exists only as a deployment secret \u2014 there is no hardcoded fallback; if the secret is absent the system refuses to operate rather than degrade.\n- **IP addresses** are hashed with a separate salted HMAC and truncated before storage. The original IP is never written to any table.\n- **All key and signature comparisons are constant-time.** No comparison short-circuits on the first mismatched byte, so timing attacks yield nothing.\n- **Outbound webhooks** are signed with HMAC-SHA256 over `{timestamp}.{payload}`, giving you both integrity and replay protection \u2014 verification rejects signatures older than 300 seconds. Your endpoint secret is shown once at creation and stored encrypted.\n- **Slack requests** are verified with Slack's `v0` signing scheme and a 5-minute timestamp tolerance.\n- **Integration credentials and webhook secrets** are encrypted at rest with AES-256-GCM, each value under its own random IV, with key material held only as a deployment secret.\n\n## Database isolation\n\nBrowser clients cannot query the database. This is enforced in layers, so no single mistake can expose data:\n\n1. **No grants.** Browser-facing database roles hold no privileges on any product table, sequence, or function. The only thing a browser can do against the database layer is authenticate.\n2. **Deny-all row-level security.** RLS is enabled on every table, and product tables carry explicit deny-all policies for client roles \u2014 so even if a grant were ever mistakenly issued, the policy layer still returns nothing.\n3. **Future-proofing.** Default privileges are configured so tables and functions added later inherit the same lockdown automatically, rather than depending on someone remembering.\n4. **All access flows through the API.** Reads and writes happen exclusively in server-side edge functions that authenticate the caller, verify org ownership, and query with service credentials. Where the dashboard needs key metadata, it reads through views that structurally exclude secret material (key hashes are not selectable, even by authenticated org members).\n5. **Authorization helpers are hardened.** The functions that back org-scoping run with pinned search paths to prevent search-path injection.\n\n## Input validation\n\n- Request bodies are capped at 64KB on every endpoint \u2014 enforced on the compressed size *and* re-checked after decompression, so compressed payloads can't smuggle larger bodies in.\n- User-controlled strings are sanitized (HTML/script metacharacters stripped) and length-capped before storage.\n- Every UUID parameter is validated against a strict format before any query runs.\n- Enumerated fields \u2014 trigger types, notification channels, member roles, issue statuses, key scopes \u2014 are validated against fixed allowlists; unknown values are rejected, not stored.\n- Numeric parameters are clamped to safe ranges with sane defaults.\n\n## Content Security Policy\n\nThe web app generates a per-request CSP with a cryptographically random nonce (16 bytes, never reused). Inline scripts without the nonce are blocked; network calls are restricted to same-origin and our auth endpoint with no wildcards; framing is denied entirely (`frame-ancestors 'none'`), and plugin/worker execution is blocked. `upgrade-insecure-requests` is set in production.\n\n## Audit logging\n\nManagement actions are recorded with actor identity (user or API key), action and target, hashed IP, hashed user-agent, and operation metadata. Nothing in the audit trail contains raw network identifiers.\n\n## Browser SDK hygiene\n\nUse attributes or stable selectors for signal context. Don't send private user data in metadata.\n\n```ts\nsignal('dead_click', {\n page: '/checkout',\n selector: '[data-action=\"continue\"]',\n})\n```\n\nDon't do this:\n\n```ts\ntrack('checkout_note', {\n email: user.email,\n message: form.message,\n})\n```\n\nPayloads are scrubbed at ingest as a backstop \u2014 keys that look like credentials or personal fields are dropped, and element labels are PII-redacted \u2014 but the safest data is data you never send.\n\n## Network-level controls\n\n- API keys support IP allowlists with CIDR notation; requests from outside the allowlist get a 403.\n- Customer webhook URLs are validated against internal, private, and metadata address ranges before any delivery, redirects are refused outright, and the same checks re-run at delivery time \u2014 so a webhook endpoint can never be used to probe internal networks.\n- The site scanner's fetches run through the same public-address validation. AI-diagnosis page fetches are made by our rendering provider against the site's own registered public URL \u2014 never an address derived from visitor input.\n- Unauthenticated browser navigations redirect to login; unauthenticated API fetches receive a 401 \u2014 the middleware distinguishes the two by fetch metadata, so APIs never leak redirect chains.\n\n## Reporting a vulnerability\n\nWe publish a `security.txt` at [flusterduck.com/.well-known/security.txt](https://flusterduck.com/.well-known/security.txt). If you find something, tell us at support@flusterduck.com \u2014 we read every report and fix what's real, regardless of severity.\n"
|
|
525
595
|
},
|
|
526
596
|
{
|
|
527
597
|
"slug": "pkg-sdk",
|
|
528
598
|
"title": "flusterduck",
|
|
529
599
|
"description": "The core browser SDK that detects friction signals.",
|
|
530
600
|
"group": "Packages",
|
|
531
|
-
"content": "# flusterduck\n\nThe browser SDK. It detects friction signals in the browser and sends them to Flusterduck. This is the core package every integration depends on.\n\n## Install\n\n```bash\nnpm install flusterduck\n```\n\n## Usage\n\n```ts\nimport { init, signal, track, identify, onSignal } from 'flusterduck'\n\ninit({\n
|
|
601
|
+
"content": "# flusterduck\n\nThe browser SDK. It detects friction signals in the browser and sends them to Flusterduck. This is the core package every integration depends on.\n\n## Install\n\n```bash\nnpm install flusterduck\n```\n\nPrefer no build step? Skip the package and drop in the script tag instead:\n\n```html\n<script src=\"https://flusterduck.com/d.js\" data-key=\"fd_pub_xxxxxxxxxxxx\" data-dnt=\"false\" async></script>\n```\n\nUse `async` (not `defer`) so a slow or unreachable CDN can never delay the host page's `DOMContentLoaded`. The bootstrap reads its config from `document.currentScript`, with a fallback to `document.querySelector('script[data-key^=\"fd_pub_\"]')` -- so a dynamically injected `<script async>` tag (tag managers, `document.createElement('script')`) works too.\n\n## Usage\n\n```ts\nimport { init, signal, track, identify, onSignal } from 'flusterduck'\n\ninit({\n key: 'fd_pub_xxxxxxxxxxxx',\n})\n\n// Identify the current session with safe, non-PII properties.\nidentify({ plan: 'scale', cohort: 'beta' })\n\n// Track a conversion event for revenue attribution.\ntrack('checkout_completed', { value: 49 })\n\n// React the instant a friction signal fires.\nonSignal((s) => {\n console.log('friction detected:', s.type)\n})\n\n// Emit a custom signal when you detect friction yourself.\nsignal('search_no_results', { query: 'pricing' })\n```\n\nUse `setConsent(true)` to gate collection behind consent, or `optOut()` to stop collection for the current session.\n\n## Links\n\nPublished on npm as `flusterduck`. Install pulls the latest published version.\n"
|
|
532
602
|
},
|
|
533
603
|
{
|
|
534
604
|
"slug": "pkg-cli",
|
|
535
605
|
"title": "flusterduck-cli",
|
|
536
606
|
"description": "CLI that detects your framework, installs, and wires up init.",
|
|
537
607
|
"group": "Packages",
|
|
538
|
-
"content":
|
|
608
|
+
"content": '# flusterduck-cli\n\nThe command line for Flusterduck: install the SDK, read scores and issues, manage issues, and record deploys \u2014 from your terminal or CI.\n\n## Install\n\n```bash\nnpx flusterduck-cli init\n```\n\nNo global install is required. Run it from your project root with `npx`.\n\n## Setup\n\n```bash\n# Detect framework, sign in with your browser, pick or create a site,\n# install packages, inject init, then verify the first event arrives.\nnpx flusterduck-cli init\n\n# Provide your publishable key non-interactively (CI, scripts).\nnpx flusterduck-cli init --key fd_pub_xxxxxxxxxxxx\n```\n\nConfirm data is flowing anytime:\n\n```bash\nnpx flusterduck-cli status --key fd_pub_xxxxxxxxxxxx --wait\n```\n\nThe CLI inspects your `package.json` and project files, picks the matching wrapper (React, Next.js, Vue, Svelte, Nuxt, or the core SDK), installs it with your package manager, and wires up initialization in the correct entry point.\n\n## Sign in once\n\nThe binary answers to both `flusterduck` and `duck`. Run `duck login` to verify and save your `fd_sec_` key locally (0600 file, readable only by you) \u2014 after that, no command needs `--key`, and a site-scoped key makes `--site` optional too. `duck logout` removes it. Explicit `--key` / `FLUSTERDUCK_SECRET_KEY` always take precedence.\n\n## Read\n\nAdd `--json` to any command for machine-readable output.\n\n```bash\nduck scores # site remembered from login\nduck scores --site <site_id> # or explicit\nnpx flusterduck-cli issues --site <site_id> --status open --limit 20\nnpx flusterduck-cli insights --site <site_id> --days 30\n```\n\n## Manage\n\n```bash\nnpx flusterduck-cli issue resolve <issue_id> --note "Fixed in #142"\nnpx flusterduck-cli issue ignore <issue_id> --note "Third-party widget"\nnpx flusterduck-cli issue reopen <issue_id>\nnpx flusterduck-cli issue start <issue_id> # moves it to in progress\n```\n\n## Record deploys\n\n```bash\nnpx flusterduck-cli deploy notify --site <site_id>\n```\n\nRun it from CI after each production deploy. Commit hash, author, and PR number are auto-detected on GitHub Actions, Vercel, GitLab, and Bitbucket; override with `--commit`, `--message`, `--author`, `--env`. Flusterduck captures confusion before and after the deploy and verifies whether your fixes actually reduced friction \u2014 see [Deploy correlation](./deploy-correlation).\n\n## Links\n\nPublished on npm as `flusterduck-cli`, with `@flusterduck/cli` as a supported scoped alias (same versions, same commands). Install pulls the latest published version. Full command reference: [CLI](./cli).\n'
|
|
539
609
|
},
|
|
540
610
|
{
|
|
541
611
|
"slug": "pkg-create",
|
|
@@ -549,35 +619,35 @@ All plans include a 7-day free trial. No credit card required.
|
|
|
549
619
|
"title": "@flusterduck/react",
|
|
550
620
|
"description": "React provider and useFlusterduck hook.",
|
|
551
621
|
"group": "Packages",
|
|
552
|
-
"content": "# @flusterduck/react\n\nThe React wrapper. Wrap your app root with `FlusterduckProvider` and signal detection starts across the whole tree. Read the SDK from any component with `useFlusterduck`.\n\n## Install\n\n```bash\nnpm install @flusterduck/react flusterduck\n```\n\n## Usage\n\n```tsx\n// main.tsx\nimport { FlusterduckProvider } from '@flusterduck/react'\nimport App from './App'\n\nexport function Root() {\n return (\n <FlusterduckProvider
|
|
622
|
+
"content": "# @flusterduck/react\n\nThe React wrapper. Wrap your app root with `FlusterduckProvider` and signal detection starts across the whole tree. Read the SDK from any component with `useFlusterduck`.\n\n## Install\n\n```bash\nnpm install @flusterduck/react flusterduck\n```\n\n## Usage\n\n```tsx\n// main.tsx\nimport { FlusterduckProvider } from '@flusterduck/react'\nimport App from './App'\n\nexport function Root() {\n return (\n <FlusterduckProvider apiKey=\"fd_pub_xxxxxxxxxxxx\">\n <App />\n </FlusterduckProvider>\n )\n}\n```\n\n```tsx\n// Any component.\nimport { useFlusterduck } from '@flusterduck/react'\n\nexport function CheckoutButton() {\n const { track } = useFlusterduck()\n return (\n <button onClick={() => track('checkout_completed', { value: 49 })}>\n Pay\n </button>\n )\n}\n```\n\n## Links\n\nPublished on npm as `@flusterduck/react`. Install pulls the latest published version.\n"
|
|
553
623
|
},
|
|
554
624
|
{
|
|
555
625
|
"slug": "pkg-next",
|
|
556
626
|
"title": "@flusterduck/next",
|
|
557
627
|
"description": "Next.js script component and hook.",
|
|
558
628
|
"group": "Packages",
|
|
559
|
-
"content": "# @flusterduck/next\n\nThe Next.js wrapper. Add `FlusterduckScript` to your root layout and signal detection starts app-wide. Use `useFlusterduck` in client components for tracking, consent, and opt-out.\n\n## Install\n\n```bash\nnpm install @flusterduck/next flusterduck\n```\n\n## Usage\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>\n <FlusterduckScript
|
|
629
|
+
"content": "# @flusterduck/next\n\nThe Next.js wrapper. Add `FlusterduckScript` to your root layout and signal detection starts app-wide. Use `useFlusterduck` in client components for tracking, consent, and opt-out.\n\n## Install\n\n```bash\nnpm install @flusterduck/next flusterduck\n```\n\n## Usage\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>\n <FlusterduckScript apiKey=\"fd_pub_xxxxxxxxxxxx\" />\n {children}\n </body>\n </html>\n )\n}\n```\n\n```tsx\n// A client component.\n'use client'\nimport { useFlusterduck } from '@flusterduck/next'\n\nexport function ConsentToggle() {\n const { setConsent, optOut } = useFlusterduck()\n return (\n <>\n <button onClick={() => setConsent(true)}>Allow</button>\n <button onClick={() => optOut()}>Opt out</button>\n </>\n )\n}\n```\n\n## Links\n\nPublished on npm as `@flusterduck/next`. Install pulls the latest published version.\n"
|
|
560
630
|
},
|
|
561
631
|
{
|
|
562
632
|
"slug": "pkg-vue",
|
|
563
633
|
"title": "@flusterduck/vue",
|
|
564
634
|
"description": "Vue plugin and composable.",
|
|
565
635
|
"group": "Packages",
|
|
566
|
-
"content": "# @flusterduck/vue\n\nThe Vue wrapper. Register the plugin once in your app entry and signal detection starts immediately. Access the SDK in any component with the `useFlusterduck` composable.\n\n## Install\n\n```bash\nnpm install @flusterduck/vue flusterduck\n```\n\n## Usage\n\n```ts\n// main.ts\nimport { createApp } from 'vue'\nimport { FlusterduckPlugin } from '@flusterduck/vue'\nimport App from './App.vue'\n\ncreateApp(App)\n .use(FlusterduckPlugin, {
|
|
636
|
+
"content": "# @flusterduck/vue\n\nThe Vue wrapper. Register the plugin once in your app entry and signal detection starts immediately. Access the SDK in any component with the `useFlusterduck` composable.\n\n## Install\n\n```bash\nnpm install @flusterduck/vue flusterduck\n```\n\n## Usage\n\n```ts\n// main.ts\nimport { createApp } from 'vue'\nimport { FlusterduckPlugin } from '@flusterduck/vue'\nimport App from './App.vue'\n\ncreateApp(App)\n .use(FlusterduckPlugin, { key: 'fd_pub_xxxxxxxxxxxx' })\n .mount('#app')\n```\n\n```vue\n<!-- Any component. -->\n<script setup lang=\"ts\">\nimport { useFlusterduck } from '@flusterduck/vue'\n\nconst { track } = useFlusterduck()\n</script>\n\n<template>\n <button @click=\"track('checkout_completed', { value: 49 })\">Pay</button>\n</template>\n```\n\n## Links\n\nPublished on npm as `@flusterduck/vue`. Install pulls the latest published version.\n"
|
|
567
637
|
},
|
|
568
638
|
{
|
|
569
639
|
"slug": "pkg-svelte",
|
|
570
640
|
"title": "@flusterduck/svelte",
|
|
571
641
|
"description": "SvelteKit wrapper.",
|
|
572
642
|
"group": "Packages",
|
|
573
|
-
"content": "# @flusterduck/svelte\n\nThe SvelteKit wrapper. Initialize once in your root layout behind a browser guard, then import the helpers you need anywhere.\n\n## Install\n\n```bash\nnpm install @flusterduck/svelte flusterduck\n```\n\n## Usage\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script lang=\"ts\">\n import { browser } from '$app/environment'\n import { initFlusterduck } from '@flusterduck/svelte'\n\n if (browser) {\n initFlusterduck({
|
|
643
|
+
"content": "# @flusterduck/svelte\n\nThe SvelteKit wrapper. Initialize once in your root layout behind a browser guard, then import the helpers you need anywhere.\n\n## Install\n\n```bash\nnpm install @flusterduck/svelte flusterduck\n```\n\n## Usage\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script lang=\"ts\">\n import { browser } from '$app/environment'\n import { initFlusterduck } from '@flusterduck/svelte'\n\n if (browser) {\n initFlusterduck({ key: 'fd_pub_xxxxxxxxxxxx' })\n }\n</script>\n\n<slot />\n```\n\n```ts\n// Any module.\nimport { track } from '@flusterduck/svelte'\n\ntrack('checkout_completed', { value: 49 })\n```\n\n## Links\n\nPublished on npm as `@flusterduck/svelte`. Install pulls the latest published version.\n"
|
|
574
644
|
},
|
|
575
645
|
{
|
|
576
646
|
"slug": "pkg-nuxt",
|
|
577
647
|
"title": "@flusterduck/nuxt",
|
|
578
648
|
"description": "Nuxt module.",
|
|
579
649
|
"group": "Packages",
|
|
580
|
-
"content": "# @flusterduck/nuxt\n\nThe Nuxt module.
|
|
650
|
+
"content": "# @flusterduck/nuxt\n\nThe Nuxt module. Create a client-side plugin returning `createFlusterduckPlugin`. All functions are SSR-safe and only run in the browser.\n\n## Install\n\n```bash\nnpm install @flusterduck/nuxt flusterduck\n```\n\n## Usage\n\n```ts\n// plugins/flusterduck.client.ts\nimport { createFlusterduckPlugin } from '@flusterduck/nuxt'\n\nexport default defineNuxtPlugin(() => {\n return createFlusterduckPlugin({\n key: useRuntimeConfig().public.flusterduckKey,\n })\n})\n```\n\n```vue\n<!-- Any component. -->\n<script setup lang=\"ts\">\nimport { track } from '@flusterduck/nuxt'\n</script>\n\n<template>\n <button @click=\"track('checkout_completed', { value: 49 })\">Pay</button>\n</template>\n```\n\n## Links\n\nPublished on npm as `@flusterduck/nuxt`. Install pulls the latest published version.\n"
|
|
581
651
|
},
|
|
582
652
|
{
|
|
583
653
|
"slug": "pkg-mcp-server",
|
|
@@ -668,7 +738,14 @@ A pre-written tooltip tells you what the author thought you'd need to know. Guid
|
|
|
668
738
|
"title": "Setting up Guide",
|
|
669
739
|
"description": "Enable Guide in init(), configure the floating button and keyboard shortcut, scope to pages.",
|
|
670
740
|
"group": "Guide",
|
|
671
|
-
"content": "# Setting up Guide\n\nGuide ships as part of the core SDK. No separate package, no browser extension, no extra script tag. Enable it in your `init()` call and it's live.\n\n## Enable Guide\n\n```ts\nimport { init } from 'flusterduck'\n\ninit({\n key: 'fd_pub_...',\n guide: {\n enabled: true,\n },\n})\n```\n\nThat's it. The SDK adds a floating help button to the page and registers the keyboard shortcut. Users activate Guide mode, hover over elements, and get explanations.\n\n## Activation mechanisms\n\nGuide is always user-triggered. It never pops up on its own. You decide how users activate it: a built-in floating button, a keyboard shortcut, your own custom trigger, or any combination.\n\n### Floating help button\n\nA small fixed-position button in the bottom-right corner of the viewport. One tap toggles Guide mode on and off.\n\n```ts\nguide: {\n enabled: true,\n fab: {\n position: 'bottom-right', // 'bottom-right' | 'bottom-left'\n offset: { x: 24, y: 24 }, // pixels from corner\n label: 'Help', // screen reader label and tooltip\n },\n}\n```\n\nIf you already have a chat widget or cookie banner in the bottom-right, move the button to `bottom-left` or adjust the offset.\n\nThe button renders inside a closed shadow root. It won't inherit your fonts, colors, or resets, and its CSS won't leak into your page.\n\n### No floating button\n\nSet `fab: false` to hide the built-in button entirely. Users activate Guide through the keyboard shortcut or your own trigger.\n\n```ts\nguide: {\n enabled: true,\n fab: false,\n}\n```\n\n### Your own trigger\n\nWire Guide to any element in your app. The `guide` export gives you programmatic control:\n\n```ts\nimport { guide } from 'flusterduck'\n\n// In your help menu\ndocument.querySelector('#my-help-btn').addEventListener('click', () => {\n guide.toggle()\n})\n\n// Or conditionally\nif (userIsNew) guide.activate()\n```\n\n`guide.activate()`, `guide.deactivate()`, `guide.toggle()`, and `guide.isActive()` work regardless of whether the FAB is visible. This is the recommended path if you want Guide to feel native to your app rather than an overlay.\n\n### Keyboard shortcut\n\nPress `?` (Shift + /) to toggle Guide mode. This matches the convention used by GitHub, Gmail, and Slack for help overlays.\n\nThe shortcut only fires when no text input is focused. If the cursor is in an `<input>`, `<textarea>`, or `contenteditable` element, the keystroke passes through normally.\n\n```ts\nguide: {\n enabled: true,\n shortcut: '?', // any single character, or false to disable\n}\n```\n\nSet `shortcut: false` if `?` conflicts with your app's own shortcuts. You can also trigger Guide mode programmatically:\n\n```ts\nimport { guide } from 'flusterduck'\n\nguide.activate() // enter Guide mode\nguide.deactivate() // exit Guide mode\nguide.toggle() // flip\n```\n\nWire these to your own button, keyboard shortcut, or menu item if you want full control over activation.\n\n### Mobile\n\nOn touch devices, the floating button still appears. When Guide mode is active, a single tap on any element shows the explanation card. Tap elsewhere or tap the button again to dismiss. No hover required.\n\nLong-press also works as a trigger: the user holds on an element for 400ms and the explanation appears. This avoids interference with normal tap interactions.\n\n## Positioning the explanation card\n\nThe explanation card appears near the cursor (or near the tapped element on mobile). It automatically flips to stay within the viewport. You don't need to configure positioning.\n\nIf you want to constrain where the card can appear:\n\n```ts\nguide: {\n enabled: true,\n card: {\n maxWidth: 320, // pixels, default 320\n offset: 12, // gap between cursor and card edge\n },\n}\n```\n\nThe card renders inside the same closed shadow root as the button. Your page styles don't affect it.\n\n## Scoping to specific pages\n\nBy default, Guide is available on every page the SDK loads on. To restrict it:\n\n```ts\nguide: {\n enabled: true,\n pages: ['/checkout', '/settings', '/onboarding/*'],\n}\n```\n\nGlob patterns work. `*` matches any single path segment. `**` matches any depth.\n\nOn pages not in the list, the button and shortcut are both hidden. The SDK still detects friction signals normally; only the Guide UI is scoped.\n\n## Bring your own API (BYOK)\n\nBy default, Guide calls the Flusterduck edge function for AI explanations, which uses your plan's included allocation. If you want to use your own OpenAI key, your own model, or a completely custom backend, point Guide at your endpoint:\n\n```ts\nguide: {\n enabled: true,\n apiEndpoint: 'https://your-server.com/api/guide-explain',\n}\n```\n\nYour endpoint receives the same POST body the built-in endpoint does:\n\n```json\n{\n \"fingerprint\": \"a1b2c3d4\",\n \"page\": \"/checkout\",\n \"context\": {\n \"selector\": \"button.submit\",\n \"label\": \"Place order\",\n \"role\": \"button\",\n \"tagName\": \"button\",\n \"isDisabled\": false,\n \"isExpanded\": false,\n \"isRequired\": false,\n \"parentLabel\": \"Payment form\",\n \"parentRole\": \"form\"\n }\n}\n```\n\nReturn a JSON response with an `explanation` field:\n\n```json\n{\n \"explanation\": \"Charges your card and places the order. You'll get a confirmation email within a few seconds.\"\n}\n```\n\nBYOK requests don't count against your Flusterduck Guide allocation. You're paying your own provider directly.\n\n## Custom explanations\n\nFor critical elements where you know exactly what the explanation should say, skip the AI entirely:\n\n```html\n<button data-fd-guide=\"Creates a new project. Doesn't affect existing projects. You can rename or delete it later from Settings.\">\n New Project\n</button>\n```\n\nWhen a user hovers over an element with `data-fd-guide`, the SDK shows that text directly. No API call, no latency, no AI cost. The attribute content always wins over generated explanations.\n\nUse this for high-traffic elements where you want guaranteed wording, or for controls that handle money, deletion, or permissions where precision matters.\n\n## Disabling Guide for specific elements\n\n```html\n<div data-fd-guide-ignore>\n <!-- nothing inside this subtree will show Guide explanations -->\n</div>\n```\n\nUseful for decorative regions, ads, or third-party embeds where explanations would be confusing or irrelevant.\n\n## Content Security Policy\n\nGuide runs entirely within the existing SDK script. No new CSP entries are needed for the UI or style injection (it uses `adoptedStyleSheets` inside a shadow root, which bypasses `style-src` restrictions).\n\nThe SDK already requires your CSP's `connect-src` to include the Flusterduck API domain for event ingestion. Guide uses the same endpoint for fetching explanations. If ingestion works, Guide works.\n\n## Privacy\n\nGuide follows the same privacy rules as the rest of the SDK. When extracting element context to generate an explanation, it reads:\n\n- Tag name, role, and accessible label\n- Element state (disabled, checked, expanded)\n- Parent context (the nearest labeled ancestor)\n- The CSS selector Flusterduck already computes for signal tracking\n\nIt never reads form values, text content the user typed, or any attribute you haven't explicitly set. The `data-fd-guide` attribute is opt-in content you control entirely.\n\nExplanation requests are scoped to your site's publishable key. Flusterduck never sends element context from one customer's site to generate explanations for another.\n"
|
|
741
|
+
"content": '# Setting up Guide\n\nGuide ships as part of the core SDK. No separate package, no browser extension, no extra script tag. Enable it in your `init()` call and it\'s live.\n\n## Enable Guide\n\n```ts\nimport { init } from \'flusterduck\'\n\ninit({\n key: \'fd_pub_...\',\n guide: {\n enabled: true,\n },\n})\n```\n\nThat\'s it. The SDK adds a floating help button to the page and registers the keyboard shortcut. Users activate Guide mode, hover over elements, and get explanations.\n\n## Modes\n\nGuide has three modes, set with `mode`:\n\n```ts\nguide: {\n enabled: true,\n mode: \'both\', // \'explain\' (default) | \'feedback\' | \'both\'\n}\n```\n\n- **`explain`** (default) \u2014 hover any element in Guide mode, get a plain-English explanation.\n- **`feedback`** \u2014 the help button opens a "Tell us what\'s confusing" box instead. Users type what lost them and submit; nothing else. No AI calls are made in this mode, so it also works on plans or pages where you don\'t want generated explanations.\n- **`both`** \u2014 hover-to-explain as normal, plus a "Still confused? Tell us" link on every explanation card that opens the feedback box.\n\nFeedback submissions appear in your dashboard under **Guide \u2192 Confusion reports**, verbatim, with the page (and the element, when the report came from an explanation card). Comments are capped at 500 characters and sanitized server-side; submissions are rate limited per visitor.\n\nCustomize the panel title:\n\n```ts\nguide: {\n enabled: true,\n mode: \'feedback\',\n feedbackPrompt: \'What were you looking for?\',\n}\n```\n\nOpen the feedback box from your own UI (a "Report a problem" menu item, for example):\n\n```ts\nimport { guide } from \'flusterduck\'\n\nguide.feedback()\n```\n\n## Activation mechanisms\n\nGuide is always user-triggered. It never pops up on its own. You decide how users activate it: a built-in floating button, a keyboard shortcut, your own custom trigger, or any combination.\n\n### Floating help button\n\nA small fixed-position button in the bottom-right corner of the viewport. One tap toggles Guide mode on and off.\n\n```ts\nguide: {\n enabled: true,\n fab: {\n position: \'bottom-right\', // \'bottom-right\' | \'bottom-left\'\n offset: { x: 24, y: 24 }, // pixels from corner\n label: \'Help\', // screen reader label and tooltip\n },\n}\n```\n\nIf you already have a chat widget or cookie banner in the bottom-right, move the button to `bottom-left` or adjust the offset.\n\nThe button renders inside a closed shadow root. It won\'t inherit your fonts, colors, or resets, and its CSS won\'t leak into your page.\n\n### No floating button\n\nSet `fab: false` to hide the built-in button entirely. Users activate Guide through the keyboard shortcut or your own trigger.\n\n```ts\nguide: {\n enabled: true,\n fab: false,\n}\n```\n\n### Your own trigger\n\nWire Guide to any element in your app. The `guide` export gives you programmatic control:\n\n```ts\nimport { guide } from \'flusterduck\'\n\n// In your help menu\ndocument.querySelector(\'#my-help-btn\').addEventListener(\'click\', () => {\n guide.toggle()\n})\n\n// Or conditionally\nif (userIsNew) guide.activate()\n```\n\n`guide.activate()`, `guide.deactivate()`, `guide.toggle()`, and `guide.isActive()` work regardless of whether the FAB is visible. This is the recommended path if you want Guide to feel native to your app rather than an overlay.\n\n### Keyboard shortcut\n\nPress `?` (Shift + /) to toggle Guide mode. This matches the convention used by GitHub, Gmail, and Slack for help overlays.\n\nThe shortcut only fires when no text input is focused. If the cursor is in an `<input>`, `<textarea>`, or `contenteditable` element, the keystroke passes through normally.\n\n```ts\nguide: {\n enabled: true,\n shortcut: \'?\', // any single character, or false to disable\n}\n```\n\nSet `shortcut: false` if `?` conflicts with your app\'s own shortcuts. You can also trigger Guide mode programmatically:\n\n```ts\nimport { guide } from \'flusterduck\'\n\nguide.activate() // enter Guide mode\nguide.deactivate() // exit Guide mode\nguide.toggle() // flip\n```\n\nWire these to your own button, keyboard shortcut, or menu item if you want full control over activation.\n\n### Mobile\n\nOn touch devices, the floating button still appears. When Guide mode is active, a single tap on any element shows the explanation card. Tap elsewhere or tap the button again to dismiss. No hover required.\n\nLong-press also works as a trigger: the user holds on an element for 400ms and the explanation appears. This avoids interference with normal tap interactions.\n\n## Positioning the explanation card\n\nThe explanation card appears near the cursor (or near the tapped element on mobile). It automatically flips to stay within the viewport. You don\'t need to configure positioning.\n\nIf you want to constrain where the card can appear:\n\n```ts\nguide: {\n enabled: true,\n card: {\n maxWidth: 320, // pixels, default 320\n offset: 12, // gap between cursor and card edge\n },\n}\n```\n\nThe card renders inside the same closed shadow root as the button. Your page styles don\'t affect it.\n\n## Scoping to specific pages\n\nBy default, Guide is available on every page the SDK loads on. To restrict it:\n\n```ts\nguide: {\n enabled: true,\n pages: [\'/checkout\', \'/settings\', \'/onboarding/*\'],\n}\n```\n\nGlob patterns work. `*` matches any single path segment. `**` matches any depth.\n\nOn pages not in the list, the button and shortcut are both hidden. The SDK still detects friction signals normally; only the Guide UI is scoped.\n\n## Server-side settings\n\nThe snippet controls how the widget looks; the dashboard controls whether it answers. Under **Settings \u2192 Guide** you can:\n\n- **Disable Guide entirely** \u2014 a real kill switch, enforced by the API. Requests are rejected server-side no matter what the client snippet asks for.\n- **Restrict the mode** \u2014 e.g. feedback-only: explanation requests get a `403 guide_explain_disabled` even if a stale snippet still asks for them.\n- **Allowlist pages** \u2014 same glob syntax as the client `pages` option, enforced at the API so the client scoping can\'t be bypassed.\n\nEverything Guide collects lands in the dashboard under **Guide**: total explanations shown, the elements users ask about most (your strongest confusion evidence), confusion reports with triage controls, and a closed-loop view of pages where Guide was active and the underlying issue has since been fixed.\n\n## Guide as an API\n\nGuide\'s backend is a plain HTTPS API authenticated with your publishable key \u2014 the same contract whether you call it from the SDK, your own client, or a native app. The base endpoint is:\n\n```\nPOST https://api.flusterduck.com/v1/guide\nx-fd-key: fd_pub_...\nContent-Type: application/json\n```\n\n### Explanation requests\n\n```json\n{\n "fingerprint": "a1b2c3d4e5f60718",\n "page": "/checkout",\n "context": {\n "selector": "button.submit",\n "label": "Place order",\n "role": "button",\n "tagName": "button",\n "isDisabled": false,\n "isExpanded": false,\n "isRequired": false,\n "parentLabel": "Payment form",\n "parentRole": "form"\n }\n}\n```\n\n`fingerprint` is any stable hash of the element + page (the SDK uses a context hash); it keys the server-side cache. `context.selector` is required; everything else is optional but improves the explanation. Response envelope:\n\n```json\n{ "data": { "explanation": "Charges your card and places the order. You\'ll get a confirmation email within a few seconds." }, "error": null }\n```\n\nExplanations are cached server-side per fingerprint, sanitized (no HTML, no URLs, no email addresses), and rate limited at 200 requests/minute per site and 15/minute per visitor IP.\n\n### Feedback submissions\n\n```json\n{\n "kind": "feedback",\n "page": "/checkout",\n "comment": "I typed my discount code but nothing happened",\n "selector": "button#apply-coupon",\n "label": "Apply coupon"\n}\n```\n\n`page` and `comment` (3\u2013500 characters) are required; `selector` and `label` are optional. Response: `{ "data": { "received": true }, "error": null }`. Rate limited at 60/minute per site and 5 per 5 minutes per visitor IP.\n\n### Errors\n\n| Status | Code | Meaning |\n| --- | --- | --- |\n| 403 | `guide_disabled` | Guide is switched off in Settings \u2192 Guide |\n| 403 | `guide_explain_disabled` | The site is in feedback-only mode |\n| 403 | `guide_feedback_disabled` | The site is in explain-only mode |\n| 403 | `guide_page_not_allowed` | The page isn\'t in the server-side allowlist |\n| 400 | `invalid_*` / `comment_too_short` | Validation failure \u2014 the message says which field |\n| 429 | `rate_limited` | Back off and retry later |\n\n## Bring your own API (BYOK)\n\nBy default, Guide calls the Flusterduck edge function for AI explanations, which uses your plan\'s included allocation. If you want to use your own Anthropic key, your own model, or a completely custom backend, point Guide at your endpoint:\n\n```ts\nguide: {\n enabled: true,\n apiEndpoint: \'https://your-server.com/api/guide-explain\',\n}\n```\n\nYour endpoint receives the exact request bodies documented above \u2014 explanation requests and, when feedback mode is on, feedback submissions (distinguished by `"kind": "feedback"`). For explanations, return JSON with an `explanation` field (a bare `{ "explanation": "..." }` or the `{ "data": { ... } }` envelope both work):\n\n```json\n{\n "explanation": "Charges your card and places the order. You\'ll get a confirmation email within a few seconds."\n}\n```\n\nBYOK requests don\'t count against your Flusterduck Guide allocation. You\'re paying your own provider directly.\n\n## Custom explanations\n\nFor critical elements where you know exactly what the explanation should say, skip the AI entirely:\n\n```html\n<button data-fd-guide="Creates a new project. Doesn\'t affect existing projects. You can rename or delete it later from Settings.">\n New Project\n</button>\n```\n\nWhen a user hovers over an element with `data-fd-guide`, the SDK shows that text directly. No API call, no latency, no AI cost. The attribute content always wins over generated explanations.\n\nUse this for high-traffic elements where you want guaranteed wording, or for controls that handle money, deletion, or permissions where precision matters.\n\n## Disabling Guide for specific elements\n\n```html\n<div data-fd-guide-ignore>\n <!-- nothing inside this subtree will show Guide explanations -->\n</div>\n```\n\nUseful for decorative regions, ads, or third-party embeds where explanations would be confusing or irrelevant.\n\n## Content Security Policy\n\nGuide runs entirely within the existing SDK script. No new CSP entries are needed for the UI or style injection (it uses `adoptedStyleSheets` inside a shadow root, which bypasses `style-src` restrictions).\n\nThe SDK already requires your CSP\'s `connect-src` to include the Flusterduck API domain for event ingestion. Guide uses the same endpoint for fetching explanations. If ingestion works, Guide works.\n\n## Privacy\n\nGuide follows the same privacy rules as the rest of the SDK. When extracting element context to generate an explanation, it reads:\n\n- Tag name, role, and accessible label\n- Element state (disabled, checked, expanded)\n- Parent context (the nearest labeled ancestor)\n- The CSS selector Flusterduck already computes for signal tracking\n\nIt never reads form values, text content the user typed, or any attribute you haven\'t explicitly set. The `data-fd-guide` attribute is opt-in content you control entirely.\n\nExplanation requests are scoped to your site\'s publishable key. Flusterduck never sends element context from one customer\'s site to generate explanations for another.\n'
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
"slug": "autofix",
|
|
745
|
+
"title": "Autofix",
|
|
746
|
+
"description": "Generate an AI fix proposal per issue: root cause, the change, an agent prompt. Never edits your repo.",
|
|
747
|
+
"group": "Issues",
|
|
748
|
+
"content": "# Autofix\n\nAutofix turns an issue's evidence into a concrete fix \u2014 a root cause, the specific change to make, likely places to look, a way to verify it, and a prompt you can paste straight into your coding agent (Claude Code, Cursor). When you connect the Flusterduck GitHub App, Autofix goes further: it researches your repository and opens a **draft pull request** with the change.\n\n## Two modes\n\n**Brief (always available).** Autofix reads the behavioral evidence already on the issue (the same no-PII data used for diagnosis) and proposes a change. You \u2014 or your agent \u2014 ship it. Flusterduck never sees your repository in this mode.\n\n**Draft PR (GitHub App connected).** With the GitHub App installed, Autofix researches the connected repo, reads the files most likely at fault, plans the smallest safe change, and opens a **draft** pull request. It only edits files it read, never creates files, and never merges. If it isn't confident it can fix the issue safely from what it can see, it falls back to the brief rather than pushing a guess.\n\nThe model that plans repo-level fixes is Claude Sonnet.\n\n## Using it\n\nOpen any issue and press **Generate fix** on the Autofix card. You get:\n\n- **Root cause** \u2014 the specific UX/code cause the evidence supports.\n- **The change** \u2014 what to change, specific enough to implement.\n- **Likely places to look** \u2014 best-guess file/component locations (never asserted as fact).\n- **How to verify** \u2014 a manual check or test to confirm the fix worked.\n- **Agent prompt** \u2014 a self-contained instruction to paste into your coding agent.\n- **Draft PR** \u2014 when the GitHub App is connected and the fix is confident, a link to the opened pull request and the files it changed.\n\nRegenerate any time to get a fresh proposal.\n\n## Limits and cost\n\nAutofix is a **count of fixes**, included by plan: Scale (5/mo), Pro (20/mo), Enterprise (unlimited). Grow includes none but can buy packs. The count only decrements on a successful generation, and resets each billing cycle.\n\nNeed more than your monthly allowance? Buy an **autofix pack** from **Settings \u2192 Billing**. Purchased fixes stack on top of your plan quota and don't expire monthly \u2014 they're spent only after the included allowance runs out.\n\nAutofix runs on its own AI budget, separate from issue diagnosis and Guide, so heavy autofix use can never starve diagnosis (and vice versa). You never see a dollar meter \u2014 just the fix count.\n\n## Safety boundary\n\nThe draft-PR boundary is deliberate: a wrong draft PR is a shrug, a wrong auto-merge is an outage. Autofix always opens PRs as drafts and never merges \u2014 you review and ship.\n"
|
|
672
749
|
},
|
|
673
750
|
{
|
|
674
751
|
"slug": "guide-explanations",
|
|
@@ -702,7 +779,7 @@ function docExcerpt(content, query, length = 240) {
|
|
|
702
779
|
}
|
|
703
780
|
function createFlusterduckMCPServer(config) {
|
|
704
781
|
const api = new FlusterduckAPI(config);
|
|
705
|
-
const server = new McpServer({ name: "flusterduck-local", version: "0.
|
|
782
|
+
const server = new McpServer({ name: "flusterduck-local", version: "0.7.0" });
|
|
706
783
|
const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
|
|
707
784
|
const writeHint = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
|
|
708
785
|
const mutateHint = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
|
|
@@ -718,6 +795,27 @@ function createFlusterduckMCPServer(config) {
|
|
|
718
795
|
status: z.string().min(1).max(80).optional(),
|
|
719
796
|
session_id: z.string().min(16).max(128).optional()
|
|
720
797
|
};
|
|
798
|
+
const exploreField = z.enum(["signal", "page", "source", "confused", "converted", "event_type"]);
|
|
799
|
+
const exploreOp = z.enum(["is", "is_not", "contains"]);
|
|
800
|
+
const exploreMetric = z.enum(["count", "avg_pageviews", "conversion_rate", "avg_dwell_ms", "bounce_rate"]);
|
|
801
|
+
const exploreGroupBy = z.enum(["page", "source", "day", "signal", "cohort"]);
|
|
802
|
+
const exploreFilter = z.object({
|
|
803
|
+
field: exploreField,
|
|
804
|
+
op: exploreOp,
|
|
805
|
+
value: z.union([z.string().min(1).max(200), z.boolean()])
|
|
806
|
+
});
|
|
807
|
+
const exploreOutput = z.union([
|
|
808
|
+
z.object({
|
|
809
|
+
mode: z.literal("list"),
|
|
810
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
811
|
+
offset: z.number().int().min(0).max(1e5).optional()
|
|
812
|
+
}),
|
|
813
|
+
z.object({
|
|
814
|
+
mode: z.literal("measure"),
|
|
815
|
+
metric: exploreMetric,
|
|
816
|
+
group_by: exploreGroupBy.optional()
|
|
817
|
+
})
|
|
818
|
+
]);
|
|
721
819
|
server.tool("get_site_context", "Get the full Flusterduck MCP snapshot for the configured site: scores, open issues, active alerts, deploys, and recommendations. Start here for any investigation.", {}, readOnly, async () => {
|
|
722
820
|
try {
|
|
723
821
|
return toolResult(await api.getContext());
|
|
@@ -818,6 +916,15 @@ function createFlusterduckMCPServer(config) {
|
|
|
818
916
|
return toolError(error);
|
|
819
917
|
}
|
|
820
918
|
});
|
|
919
|
+
server.tool("get_conversion_insights", 'Get the confused-vs-calm conversion analysis: it splits the site\'s sessions into a "confused" cohort (produced at least one friction signal) and a "calm" cohort (zero friction), then compares conversion rate, session duration, pages per session, and bounce rate between them. Use it to state plainly how much less confused sessions convert (e.g. "confused sessions convert 12 points lower"), which pages and traffic sources bleed the most conversions to confusion, and the ranked insights. cohorts/deltas are the headline; by_page and by_source break the gap down; insights[] are pre-written narratable findings (check `confident` before quoting a number, and note low_confidence cohorts). What counts as a conversion comes from the site\'s configured conversion goal. Optional days (1-90, default 7).', {
|
|
920
|
+
days: z.number().int().min(1).max(90).optional()
|
|
921
|
+
}, readOnly, async ({ days }) => {
|
|
922
|
+
try {
|
|
923
|
+
return toolResult(await api.getConversionInsights(days));
|
|
924
|
+
} catch (error) {
|
|
925
|
+
return toolError(error);
|
|
926
|
+
}
|
|
927
|
+
});
|
|
821
928
|
server.tool("get_deploys", "Get deploys known to Flusterduck for this site, including confusion_before/after scores for each.", {}, readOnly, async () => {
|
|
822
929
|
try {
|
|
823
930
|
return toolResult(await api.getDeployImpact());
|
|
@@ -881,6 +988,23 @@ function createFlusterduckMCPServer(config) {
|
|
|
881
988
|
return toolError(error);
|
|
882
989
|
}
|
|
883
990
|
});
|
|
991
|
+
server.tool(
|
|
992
|
+
"explore",
|
|
993
|
+
`Run a deterministic, typed query against this site's session data. No natural language, no LLM -- a closed vocabulary the engine validates before touching a row, built explicitly so an agent can compose it directly. Give it a window (window_days, 1-90, default 7) and up to 12 AND-ed filters over a fixed field list: signal (a friction/heuristic type, e.g. "rage_click", "dead_click", "form_abandonment"), page (exact path, or substring with op "contains"), source (referrer/utm source, exact or "contains"), confused (boolean: session produced at least one friction signal), converted (boolean: session reached the site's configured conversion event), event_type (one of click, move, scroll, keyboard, form_focus, form_blur, form_submit, touch, navigation, error, signal, pageview, custom_signal, performance, visibility, sdk_error). Ops are is / is_not / contains -- contains only makes sense for page and source; signal and the boolean fields only take is/is_not (contains is coerced to is for signal). Then choose exactly one output: {mode: "list", limit?, offset?} returns matching sessions (id, timing, pages visited, signal counts, source, confused/converted) ranked most-recent-first, limit 1-200 (default 50) with offset for paging -- use this to pull concrete example sessions as evidence. Or {mode: "measure", metric, group_by?} computes metric ("count" | "avg_pageviews" | "conversion_rate" | "avg_dwell_ms" | "bounce_rate") as a single scalar, or as a series when group_by is set ("page" | "source" | "day" | "signal" | "cohort", where "cohort" splits confused vs. calm sessions). Example: "sessions that rage-clicked on /pricing in the last 7 days" -> filters: [{field:"page",op:"is",value:"/pricing"},{field:"signal",op:"is",value:"rage_click"}], output: {mode:"list"}. Example: "does confusion actually hurt conversion here?" -> output: {mode:"measure",metric:"conversion_rate",group_by:"cohort"}.`,
|
|
994
|
+
{
|
|
995
|
+
window_days: z.number().int().min(1).max(90).optional(),
|
|
996
|
+
filters: z.array(exploreFilter).max(12).optional(),
|
|
997
|
+
output: exploreOutput
|
|
998
|
+
},
|
|
999
|
+
readOnly,
|
|
1000
|
+
async ({ window_days, filters, output }) => {
|
|
1001
|
+
try {
|
|
1002
|
+
return toolResult(await api.explore({ window_days, filters, output }));
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
return toolError(error);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
);
|
|
884
1008
|
server.tool("download_events_csv", "Return a CSV export of raw event rows for the configured site. Save the returned text as a .csv file.", {
|
|
885
1009
|
limit: z.number().int().min(1).max(1e4).optional(),
|
|
886
1010
|
sort_by: z.string().min(1).max(80).optional(),
|
package/dist/cli.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,29 @@ interface FlusterduckAPIConfig {
|
|
|
6
6
|
siteId: string;
|
|
7
7
|
orgId?: string;
|
|
8
8
|
}
|
|
9
|
+
type ExploreField = 'signal' | 'page' | 'source' | 'confused' | 'converted' | 'event_type';
|
|
10
|
+
type ExploreOp = 'is' | 'is_not' | 'contains';
|
|
11
|
+
type ExploreMetric = 'count' | 'avg_pageviews' | 'conversion_rate' | 'avg_dwell_ms' | 'bounce_rate';
|
|
12
|
+
type ExploreGroupBy = 'page' | 'source' | 'day' | 'signal' | 'cohort';
|
|
13
|
+
interface ExploreFilter {
|
|
14
|
+
field: ExploreField;
|
|
15
|
+
op: ExploreOp;
|
|
16
|
+
value: string | boolean;
|
|
17
|
+
}
|
|
18
|
+
type ExploreOutput = {
|
|
19
|
+
mode: 'list';
|
|
20
|
+
limit?: number;
|
|
21
|
+
offset?: number;
|
|
22
|
+
} | {
|
|
23
|
+
mode: 'measure';
|
|
24
|
+
metric: ExploreMetric;
|
|
25
|
+
group_by?: ExploreGroupBy;
|
|
26
|
+
};
|
|
27
|
+
interface ExploreQuery {
|
|
28
|
+
window_days?: number;
|
|
29
|
+
filters?: ExploreFilter[];
|
|
30
|
+
output: ExploreOutput;
|
|
31
|
+
}
|
|
9
32
|
declare class FlusterduckAPI {
|
|
10
33
|
private readonly config;
|
|
11
34
|
constructor(config: FlusterduckAPIConfig);
|
|
@@ -18,6 +41,7 @@ declare class FlusterduckAPI {
|
|
|
18
41
|
getSessionDetail(sessionId: string): Promise<unknown>;
|
|
19
42
|
getFlows(limit?: number): Promise<unknown>;
|
|
20
43
|
getTrends(days?: number, page?: string): Promise<unknown>;
|
|
44
|
+
getConversionInsights(days?: number): Promise<unknown>;
|
|
21
45
|
getDeployImpact(): Promise<unknown>;
|
|
22
46
|
getRecommendations(): Promise<unknown>;
|
|
23
47
|
compare(a: string, b: string): Promise<unknown>;
|
|
@@ -56,7 +80,14 @@ declare class FlusterduckAPI {
|
|
|
56
80
|
getAuditLog(limit?: number): Promise<unknown>;
|
|
57
81
|
getDegradation(): Promise<unknown>;
|
|
58
82
|
getWebhookDeliveries(limit?: number): Promise<unknown>;
|
|
83
|
+
explore(query: ExploreQuery): Promise<unknown>;
|
|
59
84
|
getIssueDetail(issueId: string): Promise<unknown>;
|
|
85
|
+
getRollup(days?: number): Promise<unknown>;
|
|
86
|
+
getElementHeatmap(page: string, selector: string): Promise<unknown>;
|
|
87
|
+
getPageHeatmap(page: string): Promise<unknown>;
|
|
88
|
+
getGuideStats(): Promise<unknown>;
|
|
89
|
+
getAlertDetail(alertId: string): Promise<unknown>;
|
|
90
|
+
getDeployDetail(deployId: string): Promise<unknown>;
|
|
60
91
|
getAlertRules(): Promise<unknown>;
|
|
61
92
|
updateAlertRule(ruleId: string, update: {
|
|
62
93
|
name?: string;
|
|
@@ -87,6 +118,7 @@ declare class FlusterduckAPI {
|
|
|
87
118
|
}): Promise<unknown>;
|
|
88
119
|
deleteAlertRule(ruleId: string): Promise<unknown>;
|
|
89
120
|
private mutate;
|
|
121
|
+
private postQuery;
|
|
90
122
|
private request;
|
|
91
123
|
private requestText;
|
|
92
124
|
}
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flusterduck/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "MCP server for UX friction data. Connect Claude, Cursor, Windsurf, Copilot, Cline to rage click and dead click analytics. Model Context Protocol.",
|
|
5
5
|
"mcpName": "com.flusterduck/mcp-server",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -49,36 +49,27 @@
|
|
|
49
49
|
"publishConfig": {
|
|
50
50
|
"access": "public"
|
|
51
51
|
},
|
|
52
|
-
"scripts": {
|
|
53
|
-
"generate:docs": "node scripts/generate-docs.mjs",
|
|
54
|
-
"prebuild": "pnpm generate:docs",
|
|
55
|
-
"build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
|
|
56
|
-
"predev": "pnpm generate:docs",
|
|
57
|
-
"dev": "tsup src/index.ts src/cli.ts --format esm --dts --watch",
|
|
58
|
-
"typecheck": "tsc --noEmit",
|
|
59
|
-
"test": "vitest run",
|
|
60
|
-
"clean": "rm -rf dist",
|
|
61
|
-
"prepublishOnly": "pnpm build"
|
|
62
|
-
},
|
|
63
52
|
"dependencies": {
|
|
64
53
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
65
54
|
"zod": "3.25.76"
|
|
66
55
|
},
|
|
67
56
|
"devDependencies": {
|
|
68
|
-
"@flusterduck/tsconfig": "workspace:*",
|
|
69
57
|
"@types/node": "22.19.17",
|
|
70
58
|
"tsup": "8.5.1",
|
|
71
59
|
"typescript": "5.9.3",
|
|
72
|
-
"vitest": "4.1.8"
|
|
73
|
-
|
|
74
|
-
"repository": {
|
|
75
|
-
"type": "git",
|
|
76
|
-
"url": "https://github.com/creayo-dev/flusterduck.git",
|
|
77
|
-
"directory": "packages/mcp-server"
|
|
60
|
+
"vitest": "4.1.8",
|
|
61
|
+
"@flusterduck/tsconfig": "0.7.2"
|
|
78
62
|
},
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
63
|
+
"author": "Creayo LLC <admin@creayodev.com>",
|
|
64
|
+
"homepage": "https://docs.flusterduck.com/mcp",
|
|
65
|
+
"scripts": {
|
|
66
|
+
"generate:docs": "node scripts/generate-docs.mjs",
|
|
67
|
+
"prebuild": "pnpm generate:docs",
|
|
68
|
+
"build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
|
|
69
|
+
"predev": "pnpm generate:docs",
|
|
70
|
+
"dev": "tsup src/index.ts src/cli.ts --format esm --dts --watch",
|
|
71
|
+
"typecheck": "tsc --noEmit",
|
|
72
|
+
"test": "vitest run",
|
|
73
|
+
"clean": "rm -rf dist"
|
|
74
|
+
}
|
|
75
|
+
}
|