Installing the widget
Client Screen is one <script> tag on the site you are building. Once it is there, the
people reviewing the site press a green “Report a problem” pill, point at what is wrong,
write a sentence, and the report arrives in your dashboard with a screenshot and every
technical detail you would otherwise have to ask for.
The snippet
Copy it from the project’s Settings → Install page, which fills in your own keys:
<script
src="https://cdn.client-screen.com/v1/widget.js"
data-key="pk_your_project_key"
data-api="https://scintillating-civet-800.eu-west-1.convex.site"
async
></script>
Paste it just before the closing </body> tag, or anywhere in <head>; both work. On a
framework, put it where that framework puts third-party scripts:
- WordPress: Appearance → Theme File Editor →
footer.php, before</body>, or any “insert header and footer scripts” plugin. - Next.js:
app/layout.tsx, inside<body>, as<Script src=… strategy="afterInteractive" …/>. - Astro, Nuxt, SvelteKit, Rails, Laravel: the shared layout template, before
</body>. - Webflow, Squarespace, Wix: the site’s “custom code / footer” settings field.
Then open the site and watch Test it now on the install page. It turns green within a few seconds of the page loading, and it names the address the widget loaded from, so a snippet pasted into the wrong site or the wrong environment is obvious immediately.
Nothing else has to change. The widget sets no cookies, ships no framework to your visitors (the pill is about 3 KB), and everything it draws lives in its own shadow root, so it cannot inherit or leak your site’s styles.
A report written while the connection is down is not lost: the widget saves it in that browser and sends it the next time the reviewer opens the site online.
Who sees the pill
That is the project’s mode, on the same settings page.
Review links (the default). The pill shows only to someone who opened a link you
sent. A review link looks like https://yoursite.com/?cs=<token>: the widget takes the
token out of the address bar, keeps it in that browser, and shows the pill on every page
of the site from then on. Reports from that link carry that person’s name, because you
gave the link a name when you made it. Use this on a live site, where the pill would
otherwise greet the public.
Staging. The pill shows to everyone. The first time someone reports, the form asks for their name and their email address; after that they are a reviewer like any other and it never asks again. The address is not optional: it is how we tell them their report arrived and how they find it later, and it is the only thing on that form that is really an identity. Use this on a staging or preview domain where everyone who can reach the site is meant to be reviewing it.
A typed address proves only that somebody can type it, so a report filed that way onto a reviewer who already exists is not shown to whoever first held that reviewer’s link. The person themselves sees it from an email link, or by signing in at the app with that address. Your own view of the project is unaffected: you see every report either way.
Either way, reports are only accepted from the domains on the project’s Domains tab.
If your site sets a Content-Security-Policy
Most sites do not. If yours does, add these sources to the directives your policy already has, with your own hosts in place of the examples. They are additions, not replacements: a policy that lists only what is below would stop your own site working.
script-src 'self' https://cdn.client-screen.com
connect-src 'self' https://scintillating-civet-800.eu-west-1.convex.site https://*.convex.cloud
img-src 'self' blob: data:
script-src— the loader, and the capture panel it loads on the first click. Both come from the same host, and the widget never fetches a library from anywhere else: its screenshot library is bundled into that file for exactly this reason.connect-src— the two hosts a report travels to: your deployment for the report itself, and*.convex.cloudfor the screenshot upload.img-src— the screenshot as it is taken (data:) and as it is shown back to the reviewer before they send it (blob:). Both are local to the browser; neither is a host.'self'in that line stands for whatever your pages already load; keep your own image sources and add the two keywords.
If your pages show images or load web fonts from somewhere else, those hosts have to be
in connect-src as well, because the screenshot is built by reading each picture’s and
each font’s bytes back out. Google Fonts, for example, needs https://fonts.googleapis.com
and https://fonts.gstatic.com. An image that cannot be read is drawn as an empty box and
a font that cannot be read falls back to a system one; the rest of the screenshot is
unaffected.
There is no style-src entry, because the widget’s CSS travels in constructed
stylesheets adopted into its shadow roots, which a policy does not cover. One caveat, so
it does not surprise you: while taking the screenshot, the capture library measures the
page by mounting a hidden copy of it with a <style> element, and a strict style-src
refuses that one element. The console says “Refused to apply inline style”; the
screenshot is taken anyway, and nothing else is affected.
apps/widget/fixtures/csp.html in this repo is a page served with default-src 'none',
the three directives above (with 'self' standing in for the CDN, the deployment and the
upload host), base-uri 'none' and form-action 'none' — nothing else at all — and
e2e/widget-csp.spec.ts files a report from it on every CI run, so this list stays true.
Telling the widget about your app
Two optional calls, both on window.ClientScreen, both safe to make on every page load
(the snippet publishes them before it does anything else, and neither throws):
<script>
// Anything you want attached to every report from this page: a build id, a plan, a
// feature flag. Strings, numbers and booleans; 2 KB in total. `null` removes a key.
ClientScreen.setContext({ build: "2026.09.13", plan: "pro" });
// Who is using the site right now, when your app already knows. On a staging project
// this is what stops the form asking, so both the name and the email address are
// required. Kept for the session only, never stored.
ClientScreen.identify({ name: "Ana Kovač", email: "ana@example.com" });
</script>
ClientScreen.open() opens the report panel from your own button. It opens whenever the
pill itself would have shown — a review link opened in that browser, or a staging project
— and does nothing otherwise, which is what keeps your button from opening a panel for a
passing member of the public. Add data-debug to the snippet and it says so in the
console.
What a report carries
So you can tell a client exactly what they are sending:
- What they pointed at, and the screenshot they marked up (blurred parts are blurred in the stored image, not just hidden behind a rectangle).
- The page address and title, the route they were on and the few routes before it.
- Browser, operating system, window size, language, time zone.
- The last 50 console messages and the last 50 failed network requests, with every query-string value stripped out — the keys stay, the values never leave the browser.
- Whatever your own
setContextcall attached.
It never reads your site’s cookies, its local storage, or anything a visitor typed into your forms.
When something is not right
- No pill. On a review-link project, open the review link itself once (
?cs=…) in that browser. Check the snippet really is on the page, and thatdata-keymatches the project. Adddata-debugto the tag and the widget explains itself in the console instead of staying quiet. - Test it now stays grey. The page has not loaded the widget yet: reload it, and
check for a
script-srcline in your policy blocking it. - Test it now is amber. The widget loaded from a host that is not on the project’s Domains list. The panel offers to add it.
- “Too many reports for now.” The project’s rate limit. It clears by itself; the message says how long.