diff --git a/apps/web/content/docs/quickstart/api-keys.mdx b/apps/web/content/docs/quickstart/api-keys.mdx
index 133a90c2..9454f33b 100644
--- a/apps/web/content/docs/quickstart/api-keys.mdx
+++ b/apps/web/content/docs/quickstart/api-keys.mdx
@@ -31,10 +31,25 @@ NEXT_PUBLIC_COSSISTANT_API_KEY=pk_live_xxxxxxxxxxxx
+For frameworks without a public env variable convention (CRA, Remix, etc.),
+pass the key directly via the `publicKey` prop:
+
+```tsx
+
+```
+
+The generic env variable only works when your bundler or server inlines
+`process.env.COSSISTANT_API_KEY` into browser code (for example a custom
+webpack `DefinePlugin`, or SSR that renders the provider with the key):
+
```bash title=".env"
COSSISTANT_API_KEY=pk_live_xxxxxxxxxxxx
```
+CRA only exposes `REACT_APP_*` variables and Remix does not expose
+`process.env` to the client at all, so on those frameworks prefer the
+`publicKey` prop.
+
@@ -42,10 +57,10 @@ COSSISTANT_API_KEY=pk_live_xxxxxxxxxxxx
Auto-detection
- The SDK automatically detects your framework and checks
- `VITE_COSSISTANT_API_KEY` (Vite), `NEXT_PUBLIC_COSSISTANT_API_KEY`
- (Next.js), or `COSSISTANT_API_KEY` (other). You can also pass the key
- explicitly through `publicKey`.
+ The SDK automatically reads `VITE_COSSISTANT_API_KEY` (Vite),
+ `NEXT_PUBLIC_COSSISTANT_API_KEY` (Next.js), or `COSSISTANT_API_KEY`
+ (only when your bundler or server inlines `process.env` into client code).
+ You can also pass the key explicitly through `publicKey`.
diff --git a/apps/web/content/docs/quickstart/browser.mdx b/apps/web/content/docs/quickstart/browser.mdx
new file mode 100644
index 00000000..508b2f3a
--- /dev/null
+++ b/apps/web/content/docs/quickstart/browser.mdx
@@ -0,0 +1,163 @@
+---
+title: Script Tag
+description: Embed the Cossistant support widget on any website with a single script tag.
+---
+
+Use the CDN embed when your site is not built with React — plain HTML,
+WordPress, Webflow, Rails, PHP, or any other stack. The loader downloads the
+widget bundle, mounts it into a shadow root, and keeps your page styles
+untouched.
+
+## 1. Add the script tag
+
+Paste one tag anywhere in `
` or `` with your public key:
+
+```html
+
+```
+
+That is the whole install. The loader reads `data-public-key` from its own
+script tag and initializes the widget automatically once the bundle loads. The
+widget waits for the DOM to be ready before mounting, so placement does not
+matter.
+
+Get your public key from the dashboard under **Settings → Developers** — see
+[API Keys](/docs/quickstart/api-keys). Only use public keys (`pk_live_*`,
+`pk_test_*`) in browser code.
+
+## 2. Pin a version (optional)
+
+`latest/` always serves the newest release. To pin an exact version, put it in
+the URL — the loader derives `widget.js` and `widget.css` from its own URL, so
+both forms work the same way:
+
+```html
+
+```
+
+Self-hosted deployments can also point the widget at their own API:
+
+```html
+
+```
+
+## The `window.Cossistant` API
+
+Once the widget runtime has loaded, `window.Cossistant` exposes:
+
+| Method | Description |
+| ----------------------- | ------------------------------------------------------------------------------ |
+| `init(options?)` | Mount the widget. Not needed when `data-public-key` is set on the script tag. |
+| `show()` | Open the widget window. |
+| `hide()` | Close the widget window. |
+| `toggle()` | Toggle the widget window. |
+| `identify(params)` | Link the visitor to a contact (`externalId`, `email`, `name`, `image`, `metadata`). |
+| `updateConfig(options)` | Update config at runtime (`open`, `defaultMessages`, `quickOptions`, `size`, `theme`, `widget`). |
+| `on(type, handler)` | Subscribe to an event. Returns an unsubscribe function. |
+| `off(type, handler)` | Remove an event handler. |
+| `destroy()` | Unmount the widget and remove it from the page. |
+
+Events for `on`/`off`: `conversationStart`, `conversationEnd`, `messageSent`,
+`messageReceived`, and `error`.
+
+`init()` accepts: `publicKey`, `apiUrl`, `wsUrl`, `autoConnect`, `container`,
+`defaultMessages`, `defaultOpen`, `quickOptions`, `size` (`"normal"` or
+`"larger"`), `theme`, `host`, and `widget`.
+
+```js
+window.Cossistant.identify({
+ externalId: "user_123",
+ email: "jane@acme.com",
+ name: "Jane Doe",
+});
+
+window.Cossistant.on("messageReceived", function (event) {
+ console.log("New message in", event.conversationId);
+});
+```
+
+The loader tag is `async`, so inline scripts usually run before
+`window.Cossistant` exists — install the queue stub below before calling any
+of these methods from inline code.
+
+## Calling the API before the widget loads
+
+`window.Cossistant` only exists once `loader.js` executes, and the script tag
+is `async`. Inline scripts that call widget methods earlier must install a
+small command queue first; every queued call is replayed in order (after any
+`data-public-key` auto-init) once the widget runtime loads:
+
+```html
+
+
+
+```
+
+With `data-public-key` on the loader tag you never need to call `init()`
+yourself — the queue stub is only required when calling widget methods before
+the bundle has loaded.
+
+## Theming
+
+The widget renders inside a shadow root, so your page CSS cannot leak in.
+Set `--co-theme-*` variables through `init()` or `updateConfig()` instead:
+
+```js
+window.Cossistant.updateConfig({
+ theme: {
+ mode: "auto", // "auto" | "light" | "dark"
+ variables: {
+ "--co-theme-primary": "#111827",
+ "--co-theme-background": "#ffffff",
+ "--co-theme-radius": "8px",
+ },
+ },
+});
+```
+
+With `mode: "auto"` (the default) the widget follows your page's color scheme:
+it checks the `dark` class, `data-color-scheme`, and `data-theme` attributes on
+``, the computed `color-scheme`, and the OS `prefers-color-scheme`, and
+reacts to changes live. The full variable list is in
+[Match Your Brand](/docs/support-component/theme).
+
+## Next steps
+
+1. [API Keys](/docs/quickstart/api-keys) to allowlist your production domains.
+2. [Match Your Brand](/docs/support-component/theme) for the full theming reference.
+3. Using React? Prefer the [React quickstart](/docs/quickstart/react) or [Next.js quickstart](/docs/quickstart) for the native SDK.
diff --git a/apps/web/content/docs/quickstart/meta.json b/apps/web/content/docs/quickstart/meta.json
index 22ad05b0..3767a9fe 100644
--- a/apps/web/content/docs/quickstart/meta.json
+++ b/apps/web/content/docs/quickstart/meta.json
@@ -1,4 +1,4 @@
{
- "pages": ["index", "react", "api-keys"],
+ "pages": ["index", "react", "browser", "api-keys"],
"title": "Quickstart"
}
diff --git a/apps/web/content/docs/quickstart/react.mdx b/apps/web/content/docs/quickstart/react.mdx
index 4cc71a49..2b0bc989 100644
--- a/apps/web/content/docs/quickstart/react.mdx
+++ b/apps/web/content/docs/quickstart/react.mdx
@@ -86,18 +86,20 @@ NEXT_PUBLIC_COSSISTANT_API_KEY=pk_test_xxxx
-For other frameworks (CRA, Remix, etc.), either set the env variable:
-
-```bash title=".env"
-COSSISTANT_API_KEY=pk_test_xxxx
-```
-
-Or pass the key directly via the `publicKey` prop:
+For other frameworks (CRA, Remix, etc.), pass the key directly via the
+`publicKey` prop:
```tsx
```
+The generic `COSSISTANT_API_KEY` env variable only works when your bundler or
+server inlines `process.env.COSSISTANT_API_KEY` into browser code (for example
+a custom webpack `DefinePlugin`, or SSR that renders the provider with the key).
+CRA only exposes `REACT_APP_*` variables and Remix does not expose `process.env`
+to the client at all, so on those frameworks the env variable never reaches the
+widget — use the `publicKey` prop instead.
+
@@ -105,9 +107,10 @@ Or pass the key directly via the `publicKey` prop:
Auto-detection
- The SDK automatically detects your framework and reads the right
- environment variable (`VITE_COSSISTANT_API_KEY`, `NEXT_PUBLIC_COSSISTANT_API_KEY`,
- or `COSSISTANT_API_KEY`). You can also pass the key explicitly through `publicKey`.
+ The SDK automatically reads `VITE_COSSISTANT_API_KEY` (Vite),
+ `NEXT_PUBLIC_COSSISTANT_API_KEY` (Next.js), or `COSSISTANT_API_KEY`
+ (only when your bundler or server inlines `process.env` into client code).
+ You can also pass the key explicitly through `publicKey`.
diff --git a/apps/web/content/docs/support-component/hooks.mdx b/apps/web/content/docs/support-component/hooks.mdx
index ab4804d9..7d9309f3 100644
--- a/apps/web/content/docs/support-component/hooks.mdx
+++ b/apps/web/content/docs/support-component/hooks.mdx
@@ -472,7 +472,11 @@ app.
import { CossistantClient } from "@cossistant/core";
import { useFileUpload } from "@cossistant/react/hooks/use-file-upload";
-const client = new CossistantClient({ publicKey: "pk_test_xxxx" });
+const client = new CossistantClient({
+ publicKey: "pk_test_xxxx",
+ apiUrl: "https://api.cossistant.com/v1",
+ wsUrl: "wss://api.cossistant.com/ws",
+});
export function FileUploader() {
const upload = useFileUpload({ client });
diff --git a/apps/web/content/docs/user-feedback/index.mdx b/apps/web/content/docs/user-feedback/index.mdx
index 00e42ed1..7abc8a8a 100644
--- a/apps/web/content/docs/user-feedback/index.mdx
+++ b/apps/web/content/docs/user-feedback/index.mdx
@@ -215,7 +215,11 @@ The most common options are:
import { CossistantClient } from "@cossistant/core";
import { useFeedbackForm } from "@cossistant/react/hooks/use-feedback-form";
-const client = new CossistantClient({ publicKey: "pk_test_xxxx" });
+const client = new CossistantClient({
+ publicKey: "pk_test_xxxx",
+ apiUrl: "https://api.cossistant.com/v1",
+ wsUrl: "wss://api.cossistant.com/ws",
+});
export function ProviderFreeFeedback({ visitorId }: { visitorId: string }) {
const feedback = useFeedbackForm({
@@ -282,7 +286,11 @@ For provider-free forms, pass a client and visitor explicitly:
import { CossistantClient } from "@cossistant/core";
import { useSubmitFeedback } from "@cossistant/react/hooks/use-submit-feedback";
-const client = new CossistantClient({ publicKey: "pk_test_xxxx" });
+const client = new CossistantClient({
+ publicKey: "pk_test_xxxx",
+ apiUrl: "https://api.cossistant.com/v1",
+ wsUrl: "wss://api.cossistant.com/ws",
+});
export function HeadlessFeedbackSubmit({ visitorId }: { visitorId: string }) {
const feedback = useSubmitFeedback({ client });
diff --git a/apps/web/src/lib/support-integration-guide.ts b/apps/web/src/lib/support-integration-guide.ts
index 01d56f4b..9ef33dde 100644
--- a/apps/web/src/lib/support-integration-guide.ts
+++ b/apps/web/src/lib/support-integration-guide.ts
@@ -208,7 +208,7 @@ export default function RootLayout({
framework: "react",
frameworkLabel: "React",
packageName: "@cossistant/react",
- envVarName: "COSSISTANT_API_KEY",
+ envVarName: "VITE_COSSISTANT_API_KEY",
envFileName: ".env",
docsQuickstartPath: "/docs/quickstart/react",
providerFileName: "src/main.tsx",
@@ -220,7 +220,7 @@ import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
-
+
@@ -306,7 +306,7 @@ import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
-
+
@@ -405,6 +405,13 @@ export function buildSupportAiSetupPrompt({
? `Use this exact public key value: ${publicApiKey}`
: "If the public key is missing, fetch it from Cossistant dashboard > Settings > Developers and replace the placeholder value.";
+ // The react guide assumes a Vite app; other bundlers need their own
+ // public-env mechanism or an explicit publicKey prop.
+ const envVarNote =
+ guide.framework === "react"
+ ? `\n "${guide.envVarName}" assumes Vite. If the project uses another bundler (CRA, Remix, etc.), use that bundler's public env variable mechanism or pass the key directly via .`
+ : "";
+
return `You are a senior ${guide.frameworkLabel} engineer. Integrate Cossistant into an existing ${guide.frameworkLabel} project.
Project context:
@@ -424,7 +431,7 @@ Required implementation:
1. Install dependency:
${installCommand}
2. Add/update ${guide.envFileName} with:
- ${guide.envVarName}=${keyValue}
+ ${guide.envVarName}=${keyValue}${envVarNote}
3. ${keyInstruction}
4. Mount at the app root.
5. Import widget CSS:
diff --git a/audit/findings.md b/audit/findings.md
new file mode 100644
index 00000000..1b510f54
--- /dev/null
+++ b/audit/findings.md
@@ -0,0 +1,1023 @@
+# Audit Findings — @cossistant/react / next / browser
+
+Generated 2026-07-02 by 12-dimension multi-agent audit (98 agents). Findings ≥medium were adversarially verified; 28 verifications failed on session limits and are listed as UNCERTAIN (to be hand-verified). 0 findings were refuted.
+
+## Dimension summaries
+
+- **api-dx**: The @cossistant/react public surface is thoughtfully designed overall — explicit exports map (no wildcards, enforced by test), compound components with rich JSDoc, and a publint-clean dist. But there are two high-impact issues: the pub:release/pub:beta/pub:next scripts run `npm publish` from the package root where npm ignores publishConfig.directory (verified via `npm pack --dry-run`: the tarball nests dist/ under a root package.json whose exports point at missing ./src files, so the next scripted release would be a fully broken install), and every primitives module plus two of the five documented provider-optional hooks lack the "use client" directive, breaking the README's own deep-import examples in Next.js RSC. Secondary DX papercuts: "./styles.css" is the only export pointing into gitignored dist/ (unresolvable on fresh clone, breaking the react-vite example), the primitives barrel drops most *Props types (Avatar's are unreachable entirely — no subpath either), and the package root re-exports the internal styled CoButton as `Button`, colliding in name with the headless Button primitive.
+
+- **provider-lifecycle**: The provider/controller lifecycle architecture is mostly well thought out (deferred destroy for StrictMode replay, useSyncExternalStore bridging, updateOptions for live config), but it has several real bugs. The worst: IdentifySupportVisitor deterministically latches `hasIdentified` before the website/visitor has loaded, so visitor identification silently never happens on normal page loads; and the provider's always-defaulted props (`defaultOpen`, `size`, `autoConnect`) are re-pushed through `updateOptions` on every effect re-run, force-closing an open widget when any unstable prop changes identity, stomping externally injected controller config, and killing the localStorage persistence of isOpen/size. Additionally, putting the `support` object in the controller useMemo deps causes a full controller/client/WebSocket teardown-and-reboot on every parent render for the common inline-object case.
+
+- **realtime**: The realtime layer's store logic (seen/typing/processing) is solid, with TTL cleanup and monotonic timestamp guards, and the event filter is correct and well-tested. The serious problems are in the connection lifecycle: events missed during a disconnect are never backfilled (messages silently vanish from open conversations), RealtimeClient.connect()/reconnect() leak duplicate sockets by not clearing the pending backoff timer, connect() cannot recover after a permanent close, and the exported RealtimeProvider permanently kills its client under React StrictMode — the exact bug class already fixed in the sibling SupportProvider. Several smaller issues (stale callbacks, per-event context re-renders that also explain the act warnings, per-render resubscription in the store hooks, and tests that assert against copies of code) round out the picture.
+
+- **hooks**: The hooks layer is generally well-engineered (SSR guards, useSyncExternalStore bridging, timer cleanups, tested draft persistence), but the shared useClientQuery fetch abstraction has three verified correctness bugs that hit every consumer: background fetch failures escape as unhandled promise rejections into the host app, store-derived refetchOnMount flags cause a duplicate network request on every successful initial load (conversation list, conversation, and timeline all double-fetch), and caller-supplied refetch args poison argsRef so switching conversations after paginating fetches the new conversation with the old conversation's cursor. The most user-damaging finding is in the composer wiring: useMessageComposer fires sendMessage.mutate without awaiting, which defeats useMultimodalInput's tested rollback contract and permanently loses the visitor's typed message, attachments, and localStorage draft when a send fails. Secondary issues include a dedup key that can make fetchNextPage a no-op, a transition hook that strands the launcher icon invisible on rapid toggles, and sound effects that are silent in Chrome/Safari because the eagerly-created AudioContext is never resumed. All findings were verified by reading the code and, where behavioral, reproduced with temporary bun tests (since removed).
+
+- **primitives-a11y**: The primitives layer is generally well-structured (consistent forwardRef + useRenderElement pattern, sensible role/aria defaults on the timeline, working focus trap and Escape handling in Window), but the core composition utility has real merge bugs: the asChild Slot clobbers child event handlers and styles, and refs are re-created every render. Two user-visible input bugs stand out — Enter submits mid-IME composition for CJK users, and the Window primitive throws without a provider despite advertising controlled props. Accessibility is partially wired: the trigger/window pair lacks aria-controls and a dialog name, the star-rating selector exposes no selected state to AT, and several timeline aria-labels are nonsensical. Timeline correctness is solid for append/replace but has no scroll anchoring for the top-pagination path it explicitly exposes, and sender precedence differs between TimelineItem and TimelineItemGroup.
+
+- **feedback**: The feedback module is well-structured (clean context layering, helpful out-of-provider errors, good SSR safety, solid hook test coverage) but the default panel ships two real user-facing bugs: failed submissions are completely silent (the hook computes submitError but the panel never renders it), and passing onClick to Feedback.Trigger silently replaces the internal toggle so the widget stops opening. There are also meaningful a11y gaps (unnamed aria-modal dialog, rating state invisible to assistive tech), an input-wipe bug when topics/defaultTopic resolve asynchronously, and a footprint miss: the module imports the full @floating-ui/react interactions package when only the 10KB @floating-ui/react-dom positioning API is used.
+
+- **support-ui**: The default widget is well-architected (compound components, slots, persisted store, locale system), but several real defects undermine it: the provider re-applies `defaultOpen` (defaulted to `false`) through `updateOptions`, which force-closes the widget on host re-renders and makes the persisted open-state feature dead code; the window primitive traps Tab for the entire document while the non-modal floating widget is open; and `theme="light"` silently doesn't force light mode. A second tier of issues blocks i18n (hardcoded English relative times and tool strings, region-subtag content overrides never resolving) and degrades a11y/styling (non-`co-` Tailwind color utilities silently missing from the published stylesheet, icon-only buttons with no accessible names).
+
+- **next-parity**: @cossistant/next is a structurally sound thin wrapper: all 9 re-export entries carry "use client" banners that are preserved verbatim in the minified dist output (verified in both the committed dist and a fresh tsdown build), styles.css/support.css are one-line @import shims onto @cossistant/react rather than stale copies, and prepare-package.ts correctly resolves workspace:* to 0.2.0. The real problems are in publish output and parity: the shipped .d.ts files import a runtime helper module that has no declaration file (verified TS7016 for consumers with skipLibCheck:false), and next exposes only 12 of react's ~41 subpath exports, so the deep-import style that react's own README advertises fails with ERR_PACKAGE_PATH_NOT_EXPORTED when ported to @cossistant/next. Remaining issues are polish: root barrel divergence (next exports utils, react does not), a broken docs URL in a near-empty npm README, dangling sourceMappingURL comments after map deletion, and an orphaned @types/react peerDependenciesMeta entry.
+
+- **browser-embed**: The embed architecture is well designed — loader-derived asset URLs (no data-attribute injection surface), shadow-DOM CSS isolation with :root-free compiled CSS, a clean teardown API, and, contrary to the audit hypothesis, the preact/compat aliasing IS wired into tsdown.embed.config.ts and verified present in dist/embed/widget.js (no React/scheduler markers). However, the core "script-tag story" is broken as documented: the README's async-loader + immediate init() snippet throws a TypeError because no pre-load stub exists, and the queue replay hard-aborts (dropping init) if any method precedes init. The 191KB-gzip widget.js is dominated by avoidable payload — all of zod v4 + @hono/zod-openapi pulled in for two zod-free helper functions, plus ~24KB of inline base64 audio — and npm packaging has real hazards (react ^19 as a hard dependency vs @cossistant/react's >=18 peer range risks dual-React crashes; the tarball ships ~580KB of duplicated vendored d.ts).
+
+- **build-packaging**: The publish pipeline's core machinery (prepare-package.ts export rewriting, rewrite-dist-types.ts, check-package-output.ts) is solid — all ~40 export subpaths verified present and correctly mapped in dist/package.json — but the release scripts themselves are a landmine: npm ignores publishConfig.directory, so running the documented pub:release from the package root packs a broken tarball (dist/-prefixed files plus a root package.json whose main/exports point at ./src/*, absent from the tarball). On footprint, the biggest issue is that zod v4 (with its full i18n locale set) plus @hono/zod-openapi is bundled into widget.js (~200KB of the 679KB) and into every @cossistant/react consumer's app bundle via @cossistant/core's client importing runtime helpers from a zod-heavy @cossistant/types module. Smaller issues: ulid is a dead dependency of @cossistant/react, dist ships minified with no sourcemaps and stripped @__PURE__ annotations, and "headless" primitives pull the full 15KB icon registry plus tailwind-merge.
+
+- **ssr-safety**: No module-scope browser-API access exists (nothing crashes at import time in Node), and nearly all window/document/navigator/observer usage is correctly confined to effects and handlers. The two real SSR problems are structural: (1) the support store synchronously rehydrates persisted state (including isOpen) from localStorage during the first client render, guaranteeing hydration mismatches for returning visitors under Next.js SSR, and (2) every ./primitives/* subpath entry plus several hook/util entries ship without "use client" (verified in both src and dist), so importing them from a Server Component crashes — a major gap for a package advertised as Next.js-ready. A cluster of render-time Date/locale formatting sites (day separators, message timestamps, formatTimeAgo, default-message timestamps that render "Invalid Date" in SSR HTML) produce hydration mismatches whenever the widget is server-rendered open (defaultOpen or persisted-open).
+
+- **docs-accuracy**: Docs accuracy is generally strong: every named import, hook signature, slot prop, data attribute, theme token, and registry claim I checked in the React/Next quickstarts, support-component pages, primitives, and user-feedback docs matches the source. The real failures cluster in three areas: (1) every provider-free snippet constructs `new CossistantClient({ publicKey })`, which fails to compile and fetches "undefined/..." at runtime because `apiUrl`/`wsUrl` are required with no defaults; (2) the browser CDN embed is broken as documented (async loader + immediate `window.Cossistant.init()` throws) and has zero docs-site coverage; (3) env-var guidance outside Next.js/Vite (the "Other" tab and the AI-prompt/onboarding code that uses `process.env.COSSISTANT_API_KEY` in Vite entry files) produces configurations that can never resolve a key in the browser.
+
+- **state-consistency**: Cross-cutting data flow in @cossistant/react is architecturally sound — optimistic sends reuse client-generated ULIDs that the server preserves, so the store's merge-by-id cleanly dedupes WS echoes, and seen/read-receipt maps are keyed to avoid double counting. The serious problems are in failure and lifecycle paths: a failed send destroys the user's typed message (composer clears before the fire-and-forget mutation settles while core removes the optimistic item), client-clock createdAt sent during conversation creation gets 400-rejected for visitors with >5-minute clock skew, nothing resyncs the timeline after a WebSocket drop, and the standalone RealtimeProvider is permanently dead under StrictMode. Secondary issues include a sticky pagination cursor in useClientQuery that leaks into later refetches/conversation switches and prop-change desync in useConversationLifecycle.
+
+## CONFIRMED findings (verified against code)
+
+### C-01 [CRITICAL] [bug] IdentifySupportVisitor latches hasIdentified before visitor loads, so identification never runs
+- **File**: packages/react/src/identify-visitor.tsx:83 | **Dimension**: provider-lifecycle
+- **Description**: The identify effect runs on mount (child effects fire before the provider's start effect even kicks off fetchWebsite), so `website` is null and `useVisitor().identify` bails with a console.warn and returns null (use-visitor.ts:118-123 `if (!visitorId) { safeWarn(...); return null; }`). The component then unconditionally calls `setHasIdentified(true)` anyway. When the website/visitor loads moments later, the effect re-runs but Case 1 is permanently blocked by `hasIdentified`, so the visitor is never associated with a contact. The same latch also permanently swallows transient network failures (controller.identify catches and returns null), and there is no in-flight guard, so StrictMode's replayed effect can fire duplicate identify POSTs when the visitor is already loaded. There are zero tests for this exported component.
+- **Evidence**: `await identify({ externalId, email, name: name ?? undefined, image: image ?? undefined });
+setHasIdentified(true);`
+- **Suggested fix**: At the top of shouldIdentify, bail early when `!visitor` (visitor not yet loaded); only call `setHasIdentified(true)` when `identify()` returns a non-null result; and guard concurrent runs with a `useRef` in-flight flag. Add a regression test that mounts IdentifySupportVisitor before the website fetch resolves and asserts identify is called once after it resolves.
+- **Verifier**: Confirmed by both code reading and an empirical reproduction against the real SupportProvider. Code chain: (1) provider.tsx renders children unconditionally and only calls controller.start() in its own effect (provider.tsx:263), which runs AFTER child effects; the controller's initial state is `website: null` with no synchronous cache hydration (core/support-controller.ts:390), so on first commit
+
+### C-02 [HIGH] [build] pub:release publishes a broken tarball: npm ignores publishConfig.directory
+- **File**: packages/react/package.json:140 | **Dimension**: api-dx
+- **Description**: The publish scripts run `npm publish` from the package root, relying on `"publishConfig": { "directory": "dist" }`. That key is a pnpm extension — npm (verified with npm 11.16.0) ignores it and packs the root layout. Verified with `npm pack --dry-run --json` in packages/react: the tarball contains 266 files all under `dist/` plus the ROOT package.json, whose `main`/`exports` point at `./src/index.ts` — which is excluded by `"files": ["dist"]`. Anyone installing that release gets a package where every import fails to resolve. The currently published 0.2.0 is dist-shaped (so a past publish was done differently, e.g. cd dist), but the checked-in scripts are the documented path and will ship a dead package next release. packages/next/package.json line 91 has the identical landmine.
+- **Evidence**: `"pub:release": "bun run build && npm publish --access public",`
+- **Suggested fix**: Publish the rewritten dist manifest directly: `"pub:release": "bun run build && npm publish ./dist --access public"` (npm packs a folder's own package.json when given a path). Apply the same to pub:beta/pub:next in both packages/react and packages/next, and delete the ignored publishConfig.directory or keep it only for pnpm users.
+- **Verifier**: Verified end-to-end. packages/react/package.json:140 runs `npm publish --access public` from the package root, relying on `publishConfig.directory: "dist"` (lines 130-133), which npm (11.16.0 installed) ignores — it is a pnpm extension. A live `npm pack --dry-run --json` in packages/react reproduces the broken tarball: 266 files, all under dist/ plus the ROOT package.json, whose main/exports point
+
+### C-03 [HIGH] [ssr] All primitives and two documented provider-optional hooks are missing "use client"
+- **File**: packages/react/src/primitives/trigger.tsx:1 | **Dimension**: api-dx
+- **Description**: The root barrel (src/index.ts), ./hooks barrel, ./provider, ./support, ./feedback and ./realtime all carry "use client", but none of the subpath-exported primitives modules do (primitives/index.ts, index.parts.ts, button.tsx, trigger.tsx, window.tsx, multimodal-input.tsx, conversation-timeline.tsx, timeline-item.tsx, timeline-item-group.tsx, router.tsx, day-separator.tsx, avatar/*), nor do hooks/use-create-conversation.ts and hooks/use-send-message.ts (while their siblings use-file-upload/use-submit-feedback/use-feedback-form have it), nor internal/hooks.ts and utils/use-render-element.tsx. All of these call React hooks (trigger.tsx uses useCallback/useStoreSelector). The README's headless quickstart tells users to `import { SupportTrigger } from "@cossistant/react/primitives/trigger"` — pasting that into a Next.js App Router file yields the confusing "useState only works in a Client Component" error instead of an automatic client boundary. tsdown demonstrably preserves the directive per-module (dist/provider.js starts with "use client"; dist/primitives/trigger.js does not), so this is purely missing source banners. Radix/shadcn-quality peers ship the directive on every client entry.
+- **Evidence**: `import * as React from "react";
+import { useStoreSelector } from "../hooks/private/store/use-store-selector";`
+- **Suggested fix**: Add "use client"; as the first line of every subpath-exported client module: all files under src/primitives/ (including avatar/*), src/hooks/use-create-conversation.ts, src/hooks/use-send-message.ts, src/internal/hooks.ts, and src/utils/use-render-element.tsx.
+- **Verifier**: Verified in source: only timeline-item-attachments.tsx among ~30 primitives files has "use client"; trigger.tsx calls React.useCallback (lines 120, 137) and useStoreSelector (line 135) with no directive. hooks/use-create-conversation.ts and use-send-message.ts lack it while sibling use-file-upload.ts has it at line 1, proving the omission is accidental. package.json exports expose all these as dir
+
+### C-04 [HIGH] [bug] Failed feedback submission is completely silent in the default panel
+- **File**: packages/react/src/feedback/components/panel.tsx:248 | **Dimension**: feedback
+- **Description**: useFeedbackForm exposes `submitError` and per-field `error` strings, but FeedbackPanel never renders any of them. On a network failure or thrown client error (e.g. 'Visitor context is unavailable.' from use-submit-feedback.ts:85), handleSubmit catches the rejection ('Error state is owned by useSubmitFeedback'), isPending flips back to false, and the button label returns to 'Send' with zero visible or announced feedback. The user believes their feedback was sent (or is confused why nothing happened) and their submission is lost. index.test.tsx:143 even asserts `expect(source).not.toContain('role="alert"')`, locking in the absence of an error surface.
+- **Evidence**: `onClick={() => {
+ void feedback.handleSubmit();
+}}`
+- **Suggested fix**: Render `feedback.submitError` (and field errors) in the form footer inside a `role="alert"` element, e.g. `{feedback.submitError ?
{feedback.submitError}
: null}`, and update the index.test.tsx assertion that currently forbids role="alert".
+- **Verifier**: Verified in code: FeedbackPanel (packages/react/src/feedback/components/panel.tsx) never renders feedback.submitError or any fields.*.error — grep confirms submitError is consumed nowhere outside use-feedback-form.ts. handleSubmit swallows rejections (use-feedback-form.ts:373 'catch { // Error state is owned by useSubmitFeedback. }'), so on network/API failure or 'Visitor context is unavailable.'
+
+### C-05 [HIGH] [bug] Consumer onClick on Feedback.Trigger silently replaces the internal toggle, breaking open/close
+- **File**: packages/react/src/feedback/internal/trigger.tsx:67 | **Dimension**: feedback
+- **Description**: FeedbackTriggerPrimitive builds its props as `{ ..., onClick: toggle, ...props }` — the consumer's rest props are spread after the internal onClick. FeedbackTriggerProps extends ButtonHTMLAttributes, so `` type-checks fine, but the spread replaces `toggle` entirely and the widget can never open. The inverse happens in asChild mode: the Slot in use-render-element.tsx (line 74, `React.cloneElement(children, { ...props, ref, className })`) overwrites the child element's own onClick with toggle, silently discarding the consumer handler. Neither path composes handlers.
+- **Evidence**: `"aria-expanded": isOpen,
+onClick: toggle,
+...props,`
+- **Suggested fix**: Compose handlers instead of overwriting: extract the consumer's onClick and call both, e.g. `onClick: (e) => { props.onClick?.(e); if (!e.defaultPrevented) toggle(); }` (Radix composeEventHandlers pattern), and do the same for the child's handlers in the Slot's cloneElement.
+- **Verifier**: Verified in code. (1) Non-asChild: packages/react/src/feedback/internal/trigger.tsx:63-68 builds props as `onClick: toggle, ...props` — the consumer rest-props spread comes after the internal handler, so a consumer onClick fully replaces toggle. The public Feedback.Trigger (src/feedback/index.tsx:213-218) is a passthrough whose props extend ButtonHTMLAttributes, so `
+
+### C-06 [HIGH] [bug] Failed message send permanently loses the visitor's typed message and attachments
+- **File**: packages/react/src/hooks/use-message-composer.ts:218 | **Dimension**: hooks
+- **Description**: useMultimodalInput.submit() implements optimistic clearing with rollback: it snapshots the message/files, clears the composer, awaits onSubmit, and on rejection restores the message, files, and localStorage draft (this contract is explicitly tested in draft-persistence.test.tsx: 'restores the draft after a failed submit' and 'keeps the persisted draft while submit is in flight'). useMessageComposer breaks the contract: its async onSubmit calls the fire-and-forget `sendMessage.mutate(...)` (which internally does `void mutateAsync(opts).catch(() => {})`) and returns immediately. submit() therefore treats every send as success: it revokes file URLs and calls reset(), which clears the persisted draft, before the network request even completes. When the send later fails, the catch/rollback path never runs, and the core client also removes the optimistic timeline item (client.ts sendMessage catch -> removeTimelineItem), so the visitor's text and attachments vanish everywhere with no way to recover. It also makes the docstring on draftPersistenceId ('message text is restored after reloads/crashes until submit succeeds') false — the draft is cleared at call time, not on success.
+- **Evidence**: `onSubmit: async ({ message: messageText, files }) => {
+ // Stop typing indicator
+ stopTyping();
+ // Send the message
+ sendMessage.mutate({`
+- **Suggested fix**: In useMessageComposer's onSubmit, await the send so failures propagate to useMultimodalInput's rollback: `await sendMessage.mutateAsync({ conversationId, message: messageText, files, ... })` (drop the inner onError wiring or dedupe the double onError call). This makes submit clear the draft only after the request succeeds and restores the composer on failure.
+- **Verifier**: Verified end-to-end. useMultimodalInput.submit() (packages/react/src/hooks/private/use-multimodal-input.ts:193-224) implements snapshot/clear/await-onSubmit with restore-on-throw, and this contract is explicitly tested (draft-persistence.test.tsx:179, :224). useMessageComposer's onSubmit (use-message-composer.ts:218) calls sendMessage.mutate(), which is fire-and-forget: use-send-message.ts:310-314
+
+### C-07 [HIGH] [bug] Every background fetch failure surfaces as an unhandled promise rejection in the host app
+- **File**: packages/react/src/hooks/private/use-client-query.ts:197 | **Dimension**: hooks
+- **Description**: execute() rethrows on failure (`throw normalized`, line 175) after storing the error in state, but all effect-driven call sites discard the promise without a catch: initial/dependency fetch (line 197 `void execute(argsRef.current)`), the refetchInterval timer (line 215), and the window-focus/visibility refetch (line 237). Any failed request from useConversations, useConversation, useConversationTimelineItems, useWebsiteStore, etc. (e.g. visitor goes offline, ad-blocker blocks the API, transient 5xx during polling) produces an unhandled promise rejection in the embedding application. Verified by mounting a probe: bun test reported the queryFn error escaping uncaught through use-client-query.ts:197. For an embeddable SDK this pollutes host error trackers (Sentry `onunhandledrejection`), spams the console, and can crash runtimes that treat unhandled rejections as fatal.
+- **Evidence**: `void execute(argsRef.current);
+...
+const normalized = toError(raw);
+setError(normalized);
+setIsLoading(false);
+throw normalized;`
+- **Suggested fix**: Only rethrow for imperative callers: have the internal effect/interval/focus call sites use `execute(argsRef.current).catch(() => {})` (error is already captured in state), or add a `shouldThrow` flag to execute that is true only when invoked via the public `refetch`.
+- **Verifier**: Verified in packages/react/src/hooks/private/use-client-query.ts: execute() rethrows after storing error in state (lines 171-175 'throw normalized'), and all three effect-driven call sites discard the promise with no catch — mount/dependency effect (line 197 'void execute(argsRef.current);'), refetchInterval timer (line 215), and focus/visibilitychange handler (line 237). The rejection path is rea
+
+### C-08 [HIGH] [bug] Store-derived refetchOnMount flag triggers a duplicate network request on every successful initial load
+- **File**: packages/react/src/hooks/private/use-client-query.ts:198 | **Dimension**: hooks
+- **Description**: The mount/refetch effect includes `refetchOnMount` in its dependency array, and once `hasMountedRef.current` is true any dep change unconditionally fetches (`shouldFetchInitially = hasMountedRef.current ? true : ...`). useConversation passes `refetchOnMount: !conversation`, useConversations passes `selection.conversations.length === 0`, and useConversationTimelineItems passes `selection.items.length === 0` — all flip from true to false exactly when the first fetch lands in the store. That flip re-runs the effect and issues a second, identical request (not deduplicated, because the first already completed and was removed from the in-flight map). Verified with a probe test: FETCH COUNT: 2. Every widget open therefore double-fetches the conversation list, and every conversation open double-fetches the conversation and its timeline page — doubling API load for all users.
+- **Evidence**: `const shouldFetchInitially = hasMountedRef.current
+ ? true
+ : refetchOnMount || !hasFetchedRef.current;
+...
+}, [enabled, execute, refetchOnMount, ...dependencies]);`
+- **Suggested fix**: Remove `refetchOnMount` from the effect dependency array and read it through a ref (`refetchOnMountRef.current`) inside the effect, so changes to the flag alone never re-trigger a fetch; only `enabled`, `execute`, and `dependencies` should re-run it.
+- **Verifier**: Independently reproduced: a probe test mounting useClientQuery with a store-derived refetchOnMount flag (exactly the useConversation pattern) yielded FETCH COUNT: 2. Mechanism verified in use-client-query.ts:187-198 — refetchOnMount is in the effect dep array, and after first mount shouldFetchInitially is unconditionally true, so the true→false flip (caused by core client ingesting the first respo
+
+### C-09 [HIGH] [bug] Stale pagination cursor is reused when switching conversations, fetching the wrong timeline page
+- **File**: packages/react/src/hooks/private/use-client-query.ts:141 | **Dimension**: hooks
+- **Description**: execute() persists caller-supplied args into argsRef (`argsRef.current = nextArgs`), and argsRef is only re-synced when `initialArgs` identity changes (lines 125-127). In useConversationTimelineItems, initialArgs is `baseArgs`, memoized solely on `options.limit`/`options.cursor` — it does NOT change when the conversation switches. So after fetchNextPage({ cursor: nextCursor }) runs for conversation A, argsRef holds A's cursor; when the user switches to conversation B, the dependency-driven effect calls `execute(argsRef.current)` and queryFn issues `getConversationTimelineItems({ conversationId: B, cursor: })`, loading a wrong/partial page (or nothing) for the new conversation. Verified with a probe test mimicking the real memoized baseArgs: CALLS ended with {"cursor":"cursor-from-conv-A","convId":"conv-B"}. The same poisoning makes refetchInterval/focus refetches re-request the old paginated page instead of the newest items after any fetchNextPage.
+- **Evidence**: `const nextArgs = args ?? argsRef.current;
+argsRef.current = nextArgs;`
+- **Suggested fix**: Do not persist explicit refetch args into argsRef (use them for that call only: `const nextArgs = args ?? argsRef.current;` without the assignment), or reset `argsRef.current = initialArgs` whenever the effect's `dependencies` change so a conversation switch always starts from the base args.
+- **Verifier**: Verified by code reading and an independent runtime probe. use-client-query.ts:141 persists explicit refetch args into argsRef, which is only re-synced when initialArgs identity changes (lines 125-127). In useConversationTimelineItems, initialArgs (baseArgs) is memoized only on options.limit/options.cursor (use-conversation-timeline-items.ts:60-70), so a conversation switch leaves argsRef holding
+
+### C-10 [HIGH] [bug] Enter submits mid-IME-composition in MultimodalInput
+- **File**: packages/react/src/primitives/multimodal-input.tsx:52 | **Dimension**: primitives-a11y
+- **Description**: The keydown handler submits on Enter without checking KeyboardEvent.isComposing (or keyCode 229). For CJK users typing with an IME, pressing Enter to confirm a candidate word fires onSubmit, sending a half-composed message. grep confirms 'isComposing' appears nowhere in the package, and the default widget composer wires this primitive directly to send (support/components/multimodal-input.tsx:210).
+- **Evidence**: `if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ onSubmit?.();`
+- **Suggested fix**: Guard the submit branch: `if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing)`.
+- **Verifier**: Verified at packages/react/src/primitives/multimodal-input.tsx:51-55: handleKeyDown submits on Enter+!shiftKey with e.preventDefault() and onSubmit?.() with no isComposing/keyCode-229 guard; grep confirms 'isComposing' appears nowhere in packages/react/src. The default widget composer (src/support/components/multimodal-input.tsx:210) wires this primitive's onSubmit to handleSubmit which sends the
+
+### C-11 [HIGH] [bug] asChild Slot clobbers the child's own event handlers and style
+- **File**: packages/react/src/utils/use-render-element.tsx:74 | **Dimension**: primitives-a11y
+- **Description**: Slot spreads the parent-injected props over the child via cloneElement, so any prop the primitive injects (onClick, onKeyDown, onScroll, style, type...) silently replaces the child's identically named prop instead of composing. Example: `` — the child's onClick is dropped and replaced by the trigger's toggle; `