Install it
Two script tags and a key. The snippets below are addressed at this deployment, so they work as pasted.
1. Get a key
Create a workspace, then Install mints an ingest key. It is shown once; what is stored is a sha256 and a readable prefix.
The key is public by design — it sits in your page, like a Sentry DSN — so it is scoped until that is survivable: it can file a report into one workspace and nothing else. It cannot read a report, list one, see a screenshot or change a setting, because none of those accept a key at all. Fill in the origins your product is served from and a lifted key cannot be used from another website.
2. Paste two tags
<!-- The flight recorder. Blocking, and as early as your template allows. -->
<script src="https://olomon.com/relay-client/recorder.js"
data-relay-surface="your-app"
data-relay-endpoint="https://olomon.com/api/relay/report"
data-relay-key="${YOUR_INGEST_KEY}"
data-relay-build="${YOUR_COMMIT_SHA}"></script>
<!-- The camera and the panel. Deferred; nothing needs it until a press. -->
<script type="module" src="https://olomon.com/relay-client/relay.js"></script>The first tag blocks on purpose. A deferred recorder installs after your own bundles and misses the requests that were already in flight — which is the most common way a feature like this ends up collecting nothing useful. It is one small file.
data-relay-build is the single highest-value attribute here. Without it, which commit the person was running is a guess, and a fix verified against the wrong one looks like it did not work.
Moving the button, or using your own
Four attributes on the same tag. Position is the one that matters: bottom right is where a chat bubble and a cookie banner already are, so a product that cannot move ours ends up with two controls on top of each other. An unrecognized value falls back rather than being applied, because a typo should not put the control somewhere nobody looks.
data-relay-position="top-left" <!-- or bottom-right (default), bottom-left, top-right --> data-relay-theme="inverse" <!-- or navy (default), light --> data-relay-label="Report a bug" <!-- default: Something's broken --> data-relay-launcher="off" <!-- render nothing; you provide the control -->
There is no color, on purpose: three weights whose contrast is known, because a pale button on a pale page means nobody reports anything and that failure looks like ours rather than like a setting. If your design system needs its own control, turn ours off and call one function from wherever you like. Alt+Shift+B keeps working either way, and this is not the same as data-relay-surface="off", which turns the reporter off altogether.
<button onClick={() => window.__olomonRelay.open()}>Report a problem</button>Changing your mind without a deploy
The same four choices are on the Install page, per surface, and what you save there wins. Your pages pick it up within about half a minute: the client asks us once, before it mounts the button, and the answer is cached at the edge. Rewording a button should not be a pull request.
So the tag is what your page has before it has heard from us, and what it keeps if it never does. Every failure means the same thing to the client: a refused origin, a rate limit, a timeout, an answer it does not recognize. An unreachable Relay costs you a customization and never the button.
A saved setting can move, reword, reweight or remove the button. It can never add one: data-relay-launcher="off" on your tag stays off whatever is saved, because putting a floating button into your product from a settings screen is not ours to do. If you would rather your tag were the only word and no request were made at all, add this.
data-relay-config="off" <!-- never ask us; the tag is the whole configuration -->
What kind of report, and how bad
The panel asks two optional questions with one tap each: what kind of report this is, from your workspace’s own types (bug, idea and question until you change them on the Routing page), and how bad it is: blocking me, getting in the way, or minor. Routing rules can send each type somewhere of its own, and the severity becomes the priority in Linear, where blocking is High and nothing a reporter picks is Urgent.
A button that is always about one kind of report can start with it chosen. So can your own control, through the same function as before. After a send, the panel says what the report was filed as when your tracker took it in time; hide that if your users should not see where reports go.
data-relay-default-type="idea" <!-- the panel opens with Idea chosen -->
data-relay-hide-ref <!-- never show the tracker's reference, or its name -->
window.__olomonRelay.open({ type: "idea" })3. Say who is reporting
No cookie of ours reaches your origin, on purpose, and the key says which workspace rather than which person. So your page is the only thing that knows who pressed the button. Pass your own signed-in user and every report carries them: the queue shows them, search finds them, and whoever reads the report can answer them.
// When your session resolves, and again whenever it changes.
// `id` is your own handle for them, and the only one of these the brief carries.
window.__olomonRelayRecorder.identify({
id: user.id,
email: user.email,
name: user.fullName,
traits: { plan: user.plan, tenant: user.orgSlug },
});
// Signing out. It replaces rather than merges, so this is the whole of it.
window.__olomonRelayRecorder.identify(null);A server-rendered template has the person in hand already and nowhere to call a function from, so the same three fields are attributes on the recorder tag. Any other data-relay-user-* becomes a trait.
data-relay-user-id="acct_8812" data-relay-user-email="dana@northwind.example" data-relay-user-name="Dana Okafor" data-relay-user-plan="team" <!-- anything else is a trait -->
Two things worth knowing. This is your page’s word rather than something we verified, because the key in it is public, so the console labels it as such and never merges it into an address a session proved. And the address and the name stay in the console: only the id and the traits go into the brief, because the brief becomes an issue body or an agent prompt, and a public repository is not somewhere to publish a customer’s email address by accident.
To make it more than your page’s word, generate an identity secret for the key on the Install page. Your server signs each person’s id and address with it, and the page passes the result as signature beside the id, or as data-relay-user-signature on the tag. A report whose signature matches is verified by your app, and may start a coding agent without waiting for a person. The Install page has the format and examples in Node, Python and Ruby.
Keep parts of the screen out
A report photographs the screen, so mark anything that must never leave it. An element with data-relay-mask is painted over in the picture with a small “Hidden” label, and nothing inside it is read: not its text, not a field’s value, not an image. It is in no part of the report, the stored copy or the brief. An element with data-relay-ignore is left out of the picture and the page outline altogether, for an overlay or a chat widget that sits in front of your product.
<section data-relay-mask>…client balances…</section> <!-- painted over, never read --> <div id="chat-widget" data-relay-ignore>…</div> <!-- not in the picture at all -->
If you cannot change the markup, list selectors on the recorder tag, or pass them from your code as early as it runs. Both add to what is masked and never take anything away.
data-relay-mask-selectors=".balance, [data-account-number]" window.__olomonRelayRecorder.mask([".balance", "[data-account-number]"]);
Password fields, and fields whose autocomplete names a card such as cc-number, are masked on every page whatever you set. The brief says how many areas were hidden, so a gray box reads as deliberate. A selector the browser cannot read captures nothing on the screen rather than everything, and the brief names it, so a typo shows up on your first report. Where anything is masked, the panel offers no real screen capture, because a screen capture cannot paint over pixels.
4. Teach it about your product
The extractors are guessing; you are not. One adapter that returns whatever actually matters is the highest-value thing you can add, and it is ranked above every inference in the brief.
window.__olomonRelayRecorder.adapter({
name: "Batch state",
match: () => location.pathname.startsWith("/payments"),
extract: () => ({
batchId: store.batch.id,
rejected: store.batch.rejected,
locked: store.batch.locked,
}),
});What happens with no endpoint
Leave data-relay-endpoint off and the button still works: it renders the brief in the browser and offers Copy and Download. That is also what happens automatically when the relay cannot be reached, so a report is never lost to a network error.
Things that are easy to get wrong
Where reports go
Routing connects Cursor, Claude, GitHub, Linear or a signed webhook with your own credentials, per workspace. Nothing is a default; a workspace with nothing connected still captures and still stores, and the console is still where a person reads them.
Redaction has three levels and the workspace’s setting is a floor: a reporter can ask for more than it and never for less.