Luuphub widget install & API
The widget is one <script> snippet. It defines a global command function, injects
a small loader from a stable URL, and mounts the core on demand.
Install
Paste this just before </head> (on Next.js, use the element under
Next.js instead), swapping lh_site_YOUR_KEY for your site's
publishable key (safe to expose — it only permits submitting feedback):
<script>!function(w,d){w.luuphub=w.luuphub||function(){(w.luuphub.q=w.luuphub.q||[]).push(arguments)};var s=d.createElement('script');s.async=1;s.src="https://w.luuphub.com/loader.js";s.setAttribute('data-lh-site',"lh_site_YOUR_KEY");d.head.appendChild(s)}(window,document);</script>
Next.js
Do not paste the snippet into a Next.js app. Load the widget the way the
Next.js scripts guide loads any
script every route needs: import Script from 'next/script' in your root layout
(app/layout.tsx or src/app/layout.tsx, and each root layout if route groups
give you several) and render this inside <body>, right after {children}:
<Script
src="https://w.luuphub.com/loader.js"
data-lh-site="lh_site_YOUR_KEY"
strategy="afterInteractive"
/>
The scripts guide's own example puts it just after </body>; either spot works,
because Next.js creates the real tag in the browser. On the Pages Router, render
the same element in pages/_app after <Component {...pageProps} />. Keep the
layout a Server Component: the element needs no 'use client' and no onLoad.
Next.js forwards data-* attributes to the real script tag and adds it to
<body> after hydration, so the served HTML proves nothing either way (the key
can appear in Next's inline data before any script has run): check the live page
in a browser, not with curl.
With a nonce-based policy, pass nonce to the element as the Next.js CSP guide
shows. The element has no queue stub, so window.luuphub exists only once the
widget has loaded: call it as window.luuphub?.('open').
Content-Security-Policy
If your site sends a Content-Security-Policy, extend it and change nothing else.
For each directive: if the policy has it, append these sources; if it lacks it but has default-src, add it with default-src's sources plus these; if it has neither, add nothing, because that type is not restricted.
| Directive | Add | Why |
|---|---|---|
script-src |
https://w.luuphub.com |
The loader and the widget code it loads. |
connect-src |
https://luuphub.com https://w.luuphub.com https://vercel.com |
Settings, the install check and feedback; the loader's asset manifest; recordings too large for the API upload, which go straight to Vercel Blob. |
img-src |
blob: data: |
Screenshot and annotation previews. |
media-src |
blob: |
Playing a recording back before it is sent. |
The widget needs no style-src, font-src or frame-src change: it styles
itself through constructable stylesheets and uses system fonts. If script-src uses a nonce, put the page's nonce on the tag you add, the way the project already does; under 'strict-dynamic' that nonce, not the host, is what lets the loader in.
If your policy forbids inline scripts and uses no nonce, load the loader as a plain
tag instead of the snippet:
<script async src="https://w.luuphub.com/loader.js" data-lh-site="lh_site_YOUR_KEY"></script>.
How it works
The snippet defines window.luuphub as a queue stub — the classic
analytics-style pattern. Any command you call before the core finishes loading is
pushed onto window.luuphub.q and replayed once the core mounts, so a call
on a cold page is never dropped. The injected loader reads its own tag's
attributes:
data-lh-site(required) — your publishablelh_site_…key.data-lh-api(optional) — override the API origin (self-hosting / staging).
Commands
Call the global with a command name and optional argument:
// Open the widget panel (e.g. from your own "Feedback" button).
luuphub('open');
// Close it.
luuphub('close');
// Attach a signed identity (see the Identify & SSO recipe).
luuphub('identify', { userId: 'u_123', email: 'a@b.com', auth: signedToken });
// Clear identity on logout.
luuphub('reset');
| Command | Argument | Effect |
|---|---|---|
open |
— | Opens the feedback panel. |
close |
— | Closes the panel. |
identify |
{ userId?, email?, auth } |
Associates submissions with a signed user. |
reset |
— | Forgets the current identity (call on logout). |
Single-page apps
The widget tracks client-side navigation automatically (history + a
lh:navigation event), so route changes in a SPA are captured with the correct
page context — no manual re-init.
Voice and video
Visitors can leave a voice note, or a video of their camera or (on desktop) their screen, from the same box they type in. Nothing extra to install.
- Permission only on a press. The widget asks for the microphone, the camera or screen capture only when the visitor presses a record button, and the recorder code loads on that first press. Loading the widget never prompts.
- Your Permissions-Policy wins. Where the page's policy blocks a feature, the
widget hides what needs it: without
microphonethere is no voice-note button; withoutcamerathe video button opens straight on screen recording on desktop and is hidden on phones and tablets; withoutdisplay-capturethere is no screen option. A browser that does not report the policy shows the button, and a refused request tells the visitor how to allow it or type instead. - Inside an iframe. If your page runs inside an
<iframe>, the frame must pass the features on withallow="microphone; camera; display-capture", or the same rules hide the buttons. - Switch a kind off. Voice notes and video are both on by default. Turn either off per site in the widget builder, under Behavior → Let visitors record.
- Screen recordings hide what you mask. While the screen records, elements
with a
data-lh-blockordata-lh-maskattribute are hidden on the page. - Strict CSP works. The recorder styles itself through constructable
stylesheets, like the rest of the widget. If your policy blocks
blob:media, an attached clip shows as a label instead of a preview and still sends. - Private to your team. Each recording is transcribed for your team, unless the site has AI switched off or has used its daily transcription allowance (60 minutes of audio a day per site on Free, 600 on paid plans). Recordings and transcripts show in your inbox only, never on the public board or in public search. A post that is only a recording waits for review.
| Recording | Longest | Largest file |
|---|---|---|
| Voice note | 3 minutes | 8 MiB |
| Video (camera or screen) | 2 minutes | 24 MiB |
Next
- Identify & SSO — sign identity server-side.
- Theming contract — brand the widget.