Userz

@userz-ai/browser

Framework-free widget core. Use directly for vanilla JS, Vue, Svelte, Solid, or as the peer dep behind @userz-ai/react.

Install

Bash
pnpm add @userz-ai/browser

Or via CDN, see CDN bundle below.

Five-minute quick start

Drop this into any page that has the App's public key. The widget mounts the bubble in the bottom-right; clicking it opens the panel. Submissions hit POST /v1/feedback immediately.

TypeScript
import { createUserz } from '@userz-ai/browser';

const userz = createUserz({
  publicKey: 'pub_GLLHdYCLb2A2d9ieY2LmmwRJ',
});

// Optional: tag every submission with route + build info.
userz.setMetadata({
  route: window.location.pathname,
  build: import.meta.env.VITE_BUILD_SHA,
});

// Optional: identify the current end-user (public mode).
userz.setUser({ email: '[email protected]', name: 'Jane Doe' });

Done. The widget handles the panel UI, screenshot capture, console-log buffering, presigned attachment upload, and the submission POST. You don't have to wire any of those up yourself.

createUserz(config)

The single entry point. Returns a Userz instance.

Config

NameTypeDescription
publicKey*stringYour App's public key, starts with pub_.
getUserToken() => string | undefined | Promise<…>Async getter for the private-mode JWT. Return undefined when no end-user is signed in; the widget will fall back to public mode if that's enabled on the App. Called on every submit, so you can return a fresh value from your auth store each time.
bubblebooleanRender the floating bubble button. Default true.
showEmailFieldbooleanShow the optional "your email" input in public mode. Default true.
consoleCapacitynumberMax console log entries kept in the ring buffer. Default 200.
captureErrorsbooleanCapture window.error and unhandledrejection into the console buffer. Default true.
targetingChordstringKey chord that activates the component-targeting overlay. Default "Ctrl+Shift+U".
mode'single' | 'session'Submission mode. "session" (default) makes the bubble click toggle session mode — the user right-clicks any element to add it, multiple items collect, "Submit all" ships them together. "single" makes the bubble click open a one-shot form; session-mode methods become no-ops.
initialUserUserIdentityInitial identity for public-mode submissions. Updatable via setUser().
brandColorstringOverride the widget accent (bubble bg + primary button). Any CSS color. Defaults to Userz mint #5ccfb4. See Theming below.
brandForegroundstringOverride the text color rendered on top of brandColor. Auto-derived from brandColor luminance if omitted.
backgroundColorstringOverride the widget panel surface (panel bg, inputs, cards). When set, the widget uses a single palette regardless of OS prefers-color-scheme. See Theming below.
textColorstringOverride the main panel text color. Pair with backgroundColor for full surface white-labeling. Either one alone is symmetric — the un-supplied side is auto-derived from luminance.

Returned instance methods

NameTypeDescription
open()voidShow the feedback panel.
close()voidHide the panel.
toggle()voidToggle visibility. Same thing the bubble does.
setUser(user)(UserIdentity | null) => voidUpdate the public-mode identity (email, name, externalUserId). Pass null to clear.
setMetadata(meta)(Record<string, unknown> | null) => voidAttach arbitrary metadata to the next submission (route, feature flags, build SHA, etc.). ≤ 4 KB JSON.
submit(input)(SubmitInput) => Promise<{ id, status }>Programmatic submission, bypasses the panel UI. Throws on validation or network error.
captureScreenshot(target?)(Element?) => Promise<{ blob, width, height }>Lazy-loads modern-screenshot, rasterizes document.documentElement, and crops to the target element (default: visible viewport).
setTargetingEnabled(on)(boolean) => voidShow/hide the component-targeting overlay.
registerTarget(t)({ el, name, meta? }) => () => voidRegister a click-target for the overlay. Returns an unregister fn.
destroy()voidTear down the widget, restore console, remove the host node.

Type definitions

The wire types are stable and re-exported from the package root:

TypeScript
type Severity = 'low' | 'medium' | 'high';

interface UserIdentity {
  externalUserId?: string;
  email?: string;
  name?: string;
}

interface AttachmentRef {
  key: string;                                      // upload key from the upload-url endpoint
  kind: 'screenshot' | 'annotated' | 'attachment';
  contentType: string;
  byteSize: number;
  width?: number;
  height?: number;
}

interface ComponentTargetData {
  name: string;
  meta?: Record<string, unknown>;
  bbox?: { x: number; y: number; w: number; h: number };
  screenshotKey?: string;                           // upload key of cropped screenshot
}

interface ConsoleLogEntry {
  level: 'log' | 'info' | 'warn' | 'error' | 'debug';
  message: string;
  ts: number;                                       // epoch ms
}

interface SubmitInput {
  text: string;                                     // 1–8192 chars
  severity?: Severity | null;
  identity?: UserIdentity;                          // public mode only
  metadata?: Record<string, unknown>;               // ≤ 4 KB JSON
  attachments?: AttachmentRef[];                    // ≤ 10
  componentTarget?: ComponentTargetData;
  withScreenshot?: boolean;                         // capture document.body before submit
}

Programmatic submission (no panel)

You don't have to use the bubble or panel, call submit() directly from your own UI. The widget still attaches console logs, captures the viewport, and (optionally) takes a screenshot for you.

TypeScript
document.querySelector('#bug-button')?.addEventListener('click', async () => {
  try {
    const { id, status } = await userz.submit({
      text: 'Cart total shows NaN after applying coupon',
      severity: 'high',
      withScreenshot: true,
      metadata: { cartId: state.cart.id, coupon: state.coupon.code },
    });
    toast.success(`Reported. Tracking id: ${id}`);
  } catch (err) {
    toast.error(err instanceof Error ? err.message : 'Could not submit feedback');
  }
});
Pass bubble: false in the config if you only want the programmatic path, the widget will still mount the panel host (for screenshot rendering) but won't show the floating button.

Theming

Four init options drive the widget's CSS custom properties on its Shadow DOM host. Pass any combination — un-supplied values fall back to the built-in light/dark defaults.

NameTypeDescription
brandColor--uz-primaryBubble background, primary button, focus rings. Default #5ccfb4 (mint).
brandForeground--uz-primary-fgText on the primary button. Auto-derived from brandColor luminance if omitted. Default #0c2a24.
backgroundColor--uz-bgPanel surface, inputs, cards. Default #ffffff (light) / #0e0f12 (dark).
textColor--uz-fgMain panel text. Default #0a0a0a (light) / #f5f5f5 (dark).
TypeScript
createUserz({
  publicKey,
  brandColor:       '#ff5500',   // accent (bubble + submit button)
  brandForeground:  '#ffffff',   // text on the accent
  backgroundColor:  '#0a0a0a',   // panel surface
  textColor:        '#f5f5f5',   // panel text
});

Muted-label (--uz-muted) and border (--uz-border) colors auto-derive from textColor + backgroundColor via color-mix(in oklch, …) so secondary text and dividers stay in harmony with the customer palette. Severity dots (--uz-sev-*), annotation preset colors, and onboarding accent stay fixed — they're semantic, not brand.

Single palette: once backgroundColor or textColor is set, the widget uses one palette regardless of the visitor's OS prefers-color-scheme. Read the preference yourself and pass the matching set if you want light/dark switching.

If you pass only one of brandColor / brandForeground (or one of backgroundColor / textColor), the widget picks the other from the paired color's WCAG-style relative luminance — so a one-line accent override won't accidentally land low-contrast text on top.

Console + error capture

On creation the widget wraps console.log/info/warn/error/debug with a ring buffer (default 200 entries, 1 KB each). The originals are still called, your devtools see exactly what they would have. The captured slice rides along with every submission so the AI agent has runtime context.

If captureErrors is on (default), window.error and unhandledrejection are also written to the buffer with prefix tags.

Sensitive values that look like authorization tokens are not redacted by the widget, the redaction step lives in our own server logger before we persist or LLM-feed anything. If you log raw secrets to the console in your own app, sanitize them before logging.

Screenshot capture

Backed by modern-screenshot, dynamically imported on the first call to captureScreenshot() so apps that never trigger one don't pay the parse/init cost. Both viewport and element captures rasterize document.documentElement and crop, with features.restoreScrollPosition on so internal overflow:auto scroll containers render at their live scrollTop instead of zero — critical in SPAs where the scrollable region is a child div rather than html/body.

TypeScript
// Capture the whole viewport
const { blob, width, height } = await userz.captureScreenshot();

// Capture a specific element
const { blob } = await userz.captureScreenshot(document.querySelector('#cart')!);

// Hand it to submit() yourself if you want to render a preview first.
// (Otherwise pass withScreenshot: true and the widget handles the upload.)

Component targeting overlay

Press Ctrl+Shift+U (configurable) to activate the overlay. Every element registered via registerTarget() gets a pulsing rectangle. Clicking one captures a cropped screenshot of just that element and pre-fills the panel with the target's name + meta.

TypeScript
const unregister = userz.registerTarget({
  el: document.querySelector('#checkout-button')!,
  name: 'CheckoutButton',
  meta: { variant: 'A', plan: 'pro' },
});

// Later, when the element unmounts:
unregister();

For React apps, use <UserzTarget> from @userz-ai/react rather than calling registerTarget directly.

Private-mode (authenticated) submissions

When your App has publicSubmissionEnabled: false, every submission must carry a JWT proving the end-user is allowed to submit feedback. Mint it on your backend (see @userz-ai/node), expose it to the browser, and return it from getUserToken:

TypeScript
// Common pattern: refresh on every submit so token rotation just works.
const userz = createUserz({
  publicKey: 'pub_...',
  getUserToken: async () => {
    if (!authStore.userzToken || authStore.userzExpiresAt < Date.now() + 60_000) {
      await authStore.refreshUserzToken(); // calls your /me endpoint
    }
    return authStore.userzToken;
  },
});

See the tokens reference for the JWT shape and lifetime rules.

Vue 3 recipe

TypeScript
// composables/useUserz.ts
import { createUserz, type Userz } from '@userz-ai/browser';
import { onBeforeUnmount, onMounted, ref } from 'vue';

export function useUserz() {
  const instance = ref<Userz | null>(null);
  onMounted(() => {
    instance.value = createUserz({
      publicKey: import.meta.env.VITE_USERZ_PUB!,
    });
  });
  onBeforeUnmount(() => instance.value?.destroy());
  return instance;
}

Svelte 5 recipe

TypeScript
// userz.svelte.ts
import { createUserz, type Userz } from '@userz-ai/browser';
import { onMount } from 'svelte';

let userz = $state<Userz | null>(null);

onMount(() => {
  userz = createUserz({
    publicKey: import.meta.env.VITE_USERZ_PUB!,
  });
  return () => userz?.destroy();
});

export function getUserz() { return userz; }

Errors thrown by submit()

The widget surfaces the API's error code as the Error.message. Pattern match on the code, not the human-readable suffix.

NameTypeDescription
invalid_body400Text empty / too long, attachment count > 10, metadata > 4 KB, etc.
unknown_app401publicKey is wrong or the App was deleted.
invalid_user_token401JWT signature failed, expired, or the app claim doesn't match this App.
origin_not_allowed403Page Origin isn't on the App's allowed origins list.
public_disabled403No JWT supplied and the App requires private mode.
rate_limited429Per-IP or per-user limit exceeded. Back off and retry.

CDN bundle

For non-React, no-build pages we publish an IIFE bundle that exposes a single global window.Userz. Current release: @userz-ai/browser@0.3.5.

HTML
<!-- Latest 1.x, recommended for most sites -->
<script src="https://cdn.userz.ai/v1/userz.js" async></script>

<!-- Pinned + integrity-checked (recommended for production) -->
<script src="https://cdn.userz.ai/v1/userz-2aa36edc80dec4bf.js" async
        integrity="sha384-2fKvaXTEgptM4Y2/VibrgrCFAl5GJCcPYP+U0KGGbPdGkxVmL/q+P16pjThzuoyU" crossorigin="anonymous"></script>

<script>
  window.addEventListener('load', () => {
    Userz.createUserz({
      publicKey: 'pub_...',
    });
  });
</script>

The IIFE bundles modern-screenshot inline, so no extra install is required for the script-tag flow.

Browser support

Evergreen Chromium, Firefox, Safari (last 2 majors). The widget uses Shadow DOM, fetch, AbortController, dynamic import(), and Intl, all well-supported targets. No polyfills shipped.