Capture widget
Settings → Capture sites. The capture widget is a small button you embed in your own web apps. It keeps the last minute or so of the page in the browser’s memory. When someone hits a problem they click it, write a sentence, and a conversation arrives in Held Open with a replay of what just happened, the console errors, the failed requests and a screenshot. Nothing leaves the browser until they press Send.
Create a site
Section titled “Create a site”A site is one embed target. It has:
- Mailbox — where captures arrive as conversations. Use a dedicated mailbox, ideally one with no workflows that send replies (see Trust and identity).
- Allowed origins — the exact origins that may submit, one per line:
https://app.example.com. No paths, no wildcards. Addhttp://localhost:5173while developing. - Keep replays for (days) — 90 by default, anywhere from 1 to 365. Only the recording is deleted when it expires; the note, screenshot and error summary stay with the conversation. Changing this affects new captures only.
- Accept captures — switch it off to pause a site without deleting it.
Each site has a site key, shown in its Embed dialog along with copy-ready snippets that already contain your address and key. You can rotate the key there (the old one stops working immediately) or delete the site; captures already received stay in their conversations.
Embed it in a React app
Section titled “Embed it in a React app”@heldopen/capture is published to Held Open’s own package registry, not the
public npm registry. Add the registry for the @heldopen scope to your app’s
.npmrc, then install the package:
@heldopen:registry=https://git.paranoidhosting.com/api/packages/held-open/npm/pnpm add @heldopen/captureThe package version comes from the release tag; install whichever version the
registry lists as latest (pre-releases are published under next).
Then wrap your app:
import { CaptureProvider } from "@heldopen/capture/react";
<CaptureProvider baseUrl="https://help.example.com" siteKey="hoc_YOUR_SITE_KEY" user={me.isInternal ? { email: me.email, name: me.name } : null} context={{ appVersion: APP_VERSION }}> <App /></CaptureProvider>user is the switch. Pass null and the widget does nothing at all: no
recorder is downloaded, no button is shown, nothing is recorded. Pass a
person only for the people who should have the button. context is a flat
map of strings (app version, plan, feature flags) attached to every capture.
Two more props: position ("bottom-left", the default, or "bottom-right")
and button. To open the form from your own menu instead of the floating
button, pass button={false} and call open() from the hook:
import { useCapture } from "@heldopen/capture/react";
const { open, status } = useCapture();status is disabled, idle (buffering in the background), recording (a
deliberate reproduction is running) or submitting.
Embed it with a script tag
Section titled “Embed it with a script tag”For apps without a build step. The Held Open address serves the loader at
/capture.js:
<script> (function (w) { var q = []; function stub(name) { return function () { q.push([name, arguments]); }; } w.HeldOpenCapture = w.HeldOpenCapture || { q: q, identify: stub("identify"), setContext: stub("setContext"), open: stub("open"), shutdown: stub("shutdown") }; })(window);</script><script src="https://help.example.com/capture.js" data-site="hoc_YOUR_SITE_KEY" async></script><script> HeldOpenCapture.identify({ email: user.email, name: user.name }); HeldOpenCapture.setContext({ appVersion: "YOUR_APP_VERSION" }); // optional</script>The first, small script is not optional. The loader tag is async, so it can
finish after your own inline code runs. Without the stub, a call like
HeldOpenCapture.identify(…) made before the loader arrives throws
ReferenceError: HeldOpenCapture is not defined. The stub defines a
placeholder that queues those calls; the loader replays them, in order, as
soon as it starts. Keep the stub before the loader tag, exactly as shown.
Nothing is recorded until identify is called with an email address, so only
call it for the people who should have the button. Two optional attributes go
on the loader tag: data-position="bottom-right" moves the button, and
data-button="false" hides it (call HeldOpenCapture.open() to show the
form yourself). HeldOpenCapture.shutdown() stops recording and removes the
button.
What is and is not recorded
Section titled “What is and is not recorded”Recording happens in the reporter’s browser and is masked before it is stored, not after.
- Form inputs are masked by default. In the replay, every input, textarea and select is recorded without its value. The screenshot blanks text-like inputs, textareas and selects only: checkbox and radio state, color and range values, and chosen file names can still show in the image.
data-ho-blockon an element replaces it with a grey box in the replay and leaves it out of the screenshot entirely, along with everything inside it.data-ho-maskon an element masks its text but keeps the layout, in both the replay and the screenshot.- Request and response bodies and headers are never captured, and request URLs are recorded without their query string or fragment.
- Canvas contents and cross-origin iframes are not recorded.
- Nothing is recorded while the tab is hidden.
In React, spread the helpers instead of typing the attribute names:
import { blockProps, maskProps } from "@heldopen/capture/react";
<section {...blockProps}>Account numbers</section><span {...maskProps}>{customer.name}</span>Two cases the default masking does not cover:
- Rich-text and
contenteditableeditors are not form inputs, so their text is recorded as typed. Putdata-ho-blockon them. - Portaled UI. Dialogs, popovers and menus that a UI library renders into
document.bodysit outside the element that opened them, so blocking the parent does not block them. Block the portaled content itself. (Held Open’s own attachment lightbox hit exactly this.)
Console output is part of the replay too, so avoid logging secrets in the apps you instrument.
What the reporter sends
Section titled “What the reporter sends”When the reporter presses Send, one submission carries:
- Their note (up to 10,000 characters). It can be the only thing they send, or they can attach a recording instead.
- A replay of the page, if recording is running. It covers roughly the last 30–60 seconds (the reporter can untick Include the last minute), or, if they choose Record a reproduction, up to 5 minutes from the moment they started.
- A screenshot of the page as it was when they pressed Send.
- A digest of the last 50 console warnings and errors and the last 50 failed requests (network errors and HTTP 400 and above) with method, path, status and timing. No bodies, no headers, and no query strings or fragments in the request URLs.
- Their identity: the email and name your app passed in. This is whatever your app claims; Held Open does not verify it.
- The page URL, in full, including query string and fragment. Do not put secrets or tokens in URLs of pages that carry the widget.
- Their browser’s user agent, viewport size and language.
- Your host context (the
contextmap), up to 4 KB.
Limits
Section titled “Limits”- The compressed replay may be up to 20 MB. If it is larger, the widget drops the oldest part and retries; if it still does not fit, the capture is sent without a replay.
- A screenshot over 5 MB is left out rather than failing the capture.
- A full page load clears the buffer. In a multi-page app a capture covers the current page only, and a single-page app keeps recording across in-app navigation.
- Replays are deleted when the site’s retention period ends; see Create a site.
- Each site accepts up to 30 captures per hour, and each reporter email up to 10 per hour. Beyond that, submissions are refused until the window passes.
Content Security Policy
Section titled “Content Security Policy”If your app sends a Content-Security-Policy header, allow your Held Open
address (shown here as https://help.example.com):
script-srchttps://help.example.com. It serves/capture.jsand the widget’s lazily loaded chunks under/capture/assets/. Those chunks are loaded with a cross-origin dynamicimport(), which needs CORS; Held Open’s/capture/assets/route already sends the required headers, so there is nothing to configure on the server.connect-srchttps://help.example.com, for the submission to/capture/v1/{siteKey}.
Two smaller things depend on how strict your policy is:
- The widget’s button and form render inside a shadow root with an inline
<style>element, so a policy without'unsafe-inline'instyle-srcwill leave them unstyled. - The screenshot is drawn in the browser from an SVG
data:URL. A policy whoseimg-srcexcludesdata:costs you the screenshot; the capture is still sent without it.
The React package bundles the widget’s chunks with your app, so script-src
is not needed for it beyond your own origin, but the submission still needs
connect-src.
If your workspace restricts access by IP address, that applies to the widget
too: /capture.js and submissions are only served to allowed addresses.
Trust and identity
Section titled “Trust and identity”- The site key is public. It sits in your page source, the same way an
analytics key does. The allowed origins list is a filter for browsers,
not a secret: it stops other websites from embedding your key, but anything
that is not a browser can send any
Originit likes. What actually protects a site is the rate limits, the size limits, a dedicated mailbox, and the ability to pause or rotate the key. - Identity is unverified. A capture names whichever email your app passed to the widget, and someone talking to the endpoint directly could name any address. That is why the widget is meant for your own team today.
- Captures never trigger automatic replies. But a workflow on the capture mailbox that sends a reply would email whatever address a capture names, and the site settings page warns about this. Point capture sites at a mailbox without reply workflows.
- Replying by hand to a capture is your call; treat the reporter address as unconfirmed.
Use it inside Held Open
Section titled “Use it inside Held Open”The in-app capture button is for Held Open super admins only, the developers of this instance. It is not available to workspace admins or to anyone else, and a super admin cannot hand it to another person. To report problems from your own web app, embed the widget there as described above under Capture sites; every workspace admin can do that.
If you are a super admin, open a site and switch on Use for Held Open’s own
interface. Only super admins see that switch. The site’s allowed origins
must include this workspace’s own address, and only one site can be the
workspace’s own at a time. Once a site is the workspace’s own, only a super
admin can edit, rotate or delete it; other admins see it marked as managed by
a Held Open super admin. In multi-tenant deployments the switch is only
available from the reserved heldopen workspace itself — a super admin
browsing any other workspace sees no self-site control there, only the site
list.
You then opt in under a workspace’s Profile settings page with Show the capture button; until you do, nothing is recorded. If you stop being a super admin, the button goes away the next time your Held Open page refreshes.
Only message bodies, the reply editor and customer contact details are blocked from those recordings. Everything else that was on screen can appear in a replay, including customer names, subject lines and conversation-list previews, so treat a recording of Held Open’s own interface as containing whatever the reporter was looking at.