@unnoodle/browser
v0.1.4
Published
Unnoodle Browser SDK — embed AI context assistance into any web application
Maintainers
Readme
@unnoodle/browser
Embed AI context assistance into any web application.
What is it?
@unnoodle/browser is a lightweight, framework-agnostic SDK that adds a floating AI assistant button to your web application. It automatically collects runtime context (current route, page title, environment) and sends it to the Unnoodle API to surface intelligent suggestions.
- Zero React / framework dependency
- Shadow DOM — completely isolated from your app's CSS
- < 50kb minified
- Works with Next.js, React, Vue, Svelte, or plain HTML
Installation
npm install @unnoodle/browser
# or
pnpm add @unnoodle/browser
# or
yarn add @unnoodle/browserQuick Start
import { Unnoodle } from "@unnoodle/browser";
Unnoodle.init({
apiKey: process.env.NEXT_PUBLIC_UNNOODLE_API_KEY,
context: "engineering",
});That's it. A floating button appears in the bottom-right corner. Click it or press ⌘U (macOS) / Ctrl+U (Windows/Linux) to open the panel.
Where is my API key? Log in to your workspace → Settings → API Key.
Full Configuration
A starter config file is included in the package as unnoodle.config.ts. Copy it into your project root and fill in your values.
import { Unnoodle } from "@unnoodle/browser";
Unnoodle.init({
// Required: workspace API key (Settings → API Key in your workspace)
apiKey: process.env.NEXT_PUBLIC_UNNOODLE_API_KEY,
// Required: context slug to load — created automatically if new
context: "engineering",
// Optional: target environment (default: "prod")
environment: "dev",
// Optional: override API base URL (default: https://api.unnoodle.com)
baseUrl: "http://localhost:8080",
// Optional: provide an auth token at request time
getAuthToken: () => localStorage.getItem("auth_token"),
// Optional: the Unnoodle design system's look applies with nothing passed.
// Match your product with a few brand roles (see "Theming" below).
theme: {
brand: { primary: "#0f766e", font: "inherit" },
},
// Optional: attach additional metadata to every request
customMetadata: () => ({
userId: window.__APP_USER_ID,
plan: "enterprise",
}),
});Programmatic Control
import { Unnoodle } from "@unnoodle/browser";
// Open the panel manually (e.g. from your own help button)
Unnoodle.open();
// Close the panel
Unnoodle.close();
// Remove the widget entirely (useful in SPAs)
Unnoodle.destroy();The individual named exports (openUnnoodle, closeUnnoodle, destroyUnnoodle) are still available for tree-shaking.
Framework Examples
React / Next.js (App Router)
Root layouts in Next.js App Router are Server Components and run on the
server where document does not exist. You must call initUnnoodle() from a
separate "use client" component rendered inside the layout body.
Step 1 — install the package
npm install @unnoodle/browser
# or
pnpm add @unnoodle/browserStep 2 — create a thin client component (e.g. components/UnnoodleInit.tsx)
"use client";
import { useEffect } from "react";
import { initUnnoodle } from "@unnoodle/browser";
export function UnnoodleInit({
apiKey,
context,
}: {
apiKey: string;
context: string;
}) {
useEffect(() => {
initUnnoodle({ apiKey, context });
}, [apiKey, context]);
return null; // renders nothing — side-effect only
}Step 3 — render it inside your layout body
// app/layout.tsx — stays a Server Component, no "use client" needed here
import { UnnoodleInit } from "../components/UnnoodleInit";
export default function RootLayout({ children }) {
return (
<html>
<body>
<UnnoodleInit
apiKey={process.env.NEXT_PUBLIC_UNNOODLE_API_KEY!}
context="engineering"
/>
{children}
</body>
</html>
);
}Why not
"use client"on the layout directly?
Next.js App Router layouts that render<html>and<head>must be Server Components. Adding"use client"would cause a build error. The thin wrapper component is the standard pattern recommended by the Next.js team.
React / Next.js (Pages Router)
// pages/_app.tsx
import { useEffect } from "react";
import { Unnoodle } from "@unnoodle/browser";
export default function App({ Component, pageProps }) {
useEffect(() => {
Unnoodle.init({
apiKey: process.env.NEXT_PUBLIC_UNNOODLE_API_KEY,
context: "engineering",
});
}, []);
return <Component {...pageProps} />;
}Vue 3
// main.ts
import { createApp } from "vue";
import App from "./App.vue";
import { Unnoodle } from "@unnoodle/browser";
const app = createApp(App);
app.mount("#app");
Unnoodle.init({
apiKey: process.env.VITE_UNNOODLE_API_KEY,
context: "engineering",
});Plain HTML / CDN
<script type="module">
import { Unnoodle } from "https://cdn.unnoodle.com/browser/latest/index.js";
Unnoodle.init({
apiKey: "pk_live_xxxxx",
context: "engineering",
});
</script>Plugin System (Advanced)
The SDK includes an internal plugin interface for advanced use cases. Plugins can intercept the context collection, response rendering, and error handling lifecycle.
import { initUnnoodle, type UnnoodlePlugin } from "@unnoodle/browser";
const analyticsPlugin: UnnoodlePlugin = {
name: "analytics",
onContextCollected: (ctx) => ({
...ctx,
sessionId: getSessionId(),
}),
onResponse: (response) => {
trackEvent("unnoodle_response", { hasActions: !!response.actions?.length });
},
onError: (err) => {
reportError(err);
},
};
Unnoodle.init({
apiKey: process.env.NEXT_PUBLIC_UNNOODLE_API_KEY,
context: "engineering",
_plugins: [analyticsPlugin],
});Note:
_pluginsis prefixed with_to indicate it's an advanced internal API. It may be promoted to a stable API in a future major version.
API Reference
Unnoodle.init(config: UnnoodleConfig): void
Initializes and mounts the widget. Safe to call in SSR environments — no-ops if document is unavailable.
Also available as the named export initUnnoodle() for tree-shaking.
openUnnoodle(): void
Programmatically opens the panel.
closeUnnoodle(): void
Programmatically closes the panel.
destroyUnnoodle(): void
Tears down the widget, removes DOM elements, and cleans up all event listeners.
Theming
The Panel, rail and launcher button render in shadow roots with the Unnoodle design system's
look — nothing to pass. To make the widget look like part of your product, pass your brand
(design record: ADR-0016, docs/engineering/systems/flow-engine/decisions/0016-widget-theming.md):
initUnnoodle({
apiKey: "pk_live_…",
context: "engineering",
theme: {
brand: {
primary: "var(--brand)", // a reference to your own CSS variable follows it live
primaryHover: "var(--brand-dark)",
secondary: "#1f2e2b", // the rail, launcher and Panel header
surface: "var(--paper)",
text: "var(--ink)",
border: "var(--line)",
font: "inherit", // your page's body font
radius: "8px",
},
},
});| Role | Used for | If not passed |
| --- | --- | --- |
| primary | filled buttons, links, focus ring, selected states | design system blue |
| primaryHover | hover on primary buttons | primary darkened 15% |
| onPrimary | text and icons on primary | white, or ink when primary is a light colour |
| secondary | the chrome: rail, launcher button, Panel header (colour or gradient) | charcoal gradient |
| secondaryHover | hover and active items on the chrome | onSecondary at 10% |
| onSecondary | icons and text on the chrome | white, or ink when secondary is a light colour |
| accent | highlights: concept chips, term links, resize handle | primary |
| surface | Panel background (lighter fills derived) | white |
| text | main text (muted and subtle shades derived) | design system ink |
| border | borders (subtle shade derived) | design system grey |
| font | font family; "inherit" = your page's body font | Poppins → system sans-serif |
| radius | base corner radius; smaller and larger radii derived | 6px |
Every value is a CSS value. Derived shades use color-mix() (Chrome 111, Safari 16.2,
Firefox 113). The automatic white-or-ink choice works for literal colours (#rgb, #rrggbb,
rgb()); for a var() or a gradient, pass onPrimary / onSecondary if white is wrong.
Dark mode is partial: surface, text and border move most of the Panel; status colours
stay light-theme.
Escape hatch. theme.tokens sets any --unn-* token by name; the names follow the design
system and may change between minor versions, so prefer brand. theme.shadowTokens (a
:host { … } block) still works. Later wins: defaults → shadowTokens → brand → tokens.
Names that are not --unn-*, unknown brand roles, and values containing ;, braces, angle
brackets, backslashes or newlines are ignored. theme.primaryColor and theme.borderRadius
are deprecated aliases of brand.primary and brand.radius.
The defaults are the design system's :root tokens (ui/designsystem/src/styles/globals.css),
copied into src/ui/theme/defaultTokens.generated.ts by pnpm tokens; a test fails when the
two drift or when the widget uses a token without a default.
UnnoodleConfig Interface
| Property | Type | Required | Default | Description |
| -------------------- | ------------------------------- | -------- | -------------------------- | --------------------------------------------------------------- |
| apiKey | string | ✅ | — | Workspace API key (pk_live_…). Get it from Settings → API Key |
| context | string | ✅ | — | Context slug to load (auto-created if new) |
| environment | "dev" \| "staging" \| "prod" | | "prod" | Target environment |
| baseUrl | string | | https://api.unnoodle.com | API base URL override |
| getAuthToken | () => string \| null | | — | Returns current user's auth token |
| theme.brand | UnnoodleBrand | | design system look | Your brand: primary, secondary, hover, font, radius… (Theming) |
| theme.tokens | Record<string, string> | | — | Any --unn-* token by name; escape hatch (Theming) |
| theme.shadowTokens | string | | — | A :host { … } block of --unn-* tokens |
| customMetadata | () => Record<string, unknown> | | — | Additional context metadata |
| _plugins | UnnoodlePlugin[] | | — | Internal plugin hooks |
Security
- Auth tokens are resolved at request time and never stored
- All API communication uses HTTPS
- No secrets are bundled in the SDK
- Shadow DOM prevents CSS bleed-in and bleed-out
- The SDK fails silently on misconfiguration — it will never crash the host app
Product Memory — Developer API (V2)
The V2 Product Memory sub-system ships as part of the package and is exported for advanced SDK consumers. These modules work in any browser environment and have no React/framework dependency.
StateWatcher
Attaches a MutationObserver to any DOM element and fires a typed StateDiff callback when meaningful changes are detected.
import { StateWatcher, type StateDiff } from "@unnoodle/browser";
const watcher = new StateWatcher();
const btn = document.getElementById("submit-btn")!;
watcher.watch(btn, (diff: StateDiff) => {
console.log(diff.type); // "text" | "attribute" | "structure"
console.log(diff.before); // value before the change
console.log(diff.after); // value after the change
console.log(diff.attribute); // attribute name (only when type === "attribute")
});
// Clean up
watcher.unwatch();Behaviour:
- Changes are debounced for 500 ms — rapid mutations produce a single callback.
styleattribute mutations are filtered out (noise filter).- Text changes (
textContentassignments) are classified astype: "text". - Attribute changes (class, disabled, aria-*) are classified as
type: "attribute". - Child element additions/removals are classified as
type: "structure". unwatch()cancels any pending callback and disconnects the observer.
inferMeaning(diff, el)
Rule-based semantic label inference from a StateDiff.
import { StateWatcher, inferMeaning } from "@unnoodle/browser";
const watcher = new StateWatcher();
watcher.watch(document.getElementById("submit-btn")!, (diff) => {
const meaning = inferMeaning(diff, document.getElementById("submit-btn")!);
// "Loading state" | "Error state" | "Disabled state" | … | null
if (meaning) {
console.log("Detected state:", meaning);
}
});Inferred labels:
| Trigger | Label |
| --------------------------------------------------- | ----------------------------- |
| Text contains "loading" / "submitting" / "saving" | "Loading state" |
| Text contains "error" / "failed" / "invalid" | "Error state" |
| Text contains "success" / "saved" / "done" | "Success state" |
| Class "loading" / "spinner" / "pending" added | "Loading state" |
| Class "disabled" / "inactive" added | "Disabled state" |
| Class "error" / "invalid" / "danger" added | "Error state" |
| Class "active" / "selected" / "open" added | "Active state" |
| Class "collapsed" / "hidden" added | "Collapsed state" |
| disabled attribute added | "Disabled state" |
| aria-expanded → "true" | "Expanded state" |
| aria-expanded → "false" | "Collapsed state" |
| aria-busy → "true" | "Loading state" |
| aria-label changed | "Label changed to: <value>" |
| Structure change or unknown | null |
LiveRenderer
Highlights a live DOM element with a fixed-position ring (useful for showing which element a stored ProductMemory state belongs to).
import { LiveRenderer } from "@unnoodle/browser";
const renderer = new LiveRenderer();
// Highlight an element
renderer.show(document.getElementById("submit-btn")!);
// → scrolls element into view + draws violet highlight ring
// Remove highlight
renderer.hide();Calling hide() before show() is safe. Calling show() twice replaces the previous ring.
Changelog
See CHANGELOG.md for version history.
Release
Releases are published by GitHub Actions (.github/workflows/publish-browser.yml),
never from a laptop. Pushing a tag browser-v<version> builds, tests and
publishes that version to npm, signed in with npm trusted publishing (no npm
token is stored anywhere).
cd ui/browser
npm run release:patch # or release:minor / release:major — bumps package.json only
VERSION=$(node -p "require('./package.json').version")
git commit -am "chore(browser): release $VERSION"
git tag "browser-v$VERSION"
git push origin main "browser-v$VERSION"The workflow refuses to publish if the tag does not match package.json, if
the version is already on npm, if the tests fail, or if the packed tarball
does not install and import in an empty project.
To check a release without publishing: GitHub → Actions → "Publish @unnoodle/browser" → Run workflow (a dry run: everything but the publish).
One-time setup (already done if a release has gone out): on npmjs.com,
@unnoodle/browser → Settings → Trusted publishing → GitHub Actions, with
organization unnoodle, repository unnoodle, workflow publish-browser.yml.
License
MIT © Unnoodle
