Relay
DocsSign inStart free

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

A few 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. When every corner is taken, by a sidebar footer as much as a widget, move it in from its corner and choose its layer. An unrecognized value falls back rather than being applied, because a typo should not put the control somewhere nobody looks.

data-relay-position="bottom-left"  <!-- or bottom-right (default), top-left, top-right -->
data-relay-offset-x="240"         <!-- pixels in from that corner's side: past your sidebar -->
data-relay-offset-y="64"          <!-- pixels up from the bottom (or down from the top) -->
data-relay-z-index="1000"         <!-- its layer; default above everything -->
data-relay-theme="inverse"        <!-- or navy (default), light -->
data-relay-label="Report a bug"   <!-- default: Something's broken -->

An offset stops at the far edge of the screen, so one chosen beside a desktop sidebar still leaves the button on a phone, and the panel always opens above everything whatever the button’s layer. 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, put it anywhere, a user menu, a footer, a help page, and mark it. Any element marked data-relay-trigger opens the same panel our button would, with the same page, masks and signed-in person, and while one is in the page we draw no button of ours. Give it a type and the panel opens with that type chosen. From code, it is one function.

<button type="button" data-relay-trigger>Report an issue</button>
<a href="#" data-relay-trigger="idea">Suggest something</a>

window.__olomonRelay.open()

A trigger that is only in the page while its menu is open cannot keep our button away by itself, so say so on the tag. Or draw nothing at all and let a key combination be the way in. Alt+Shift+B opens the panel on every install; choose another if your product already uses it. Neither is the same as data-relay-surface="off", which turns the reporter off altogether.

data-relay-launcher="inline"      <!-- never draw ours; your trigger or open() is the way in -->
data-relay-launcher="shortcut"    <!-- draw nothing; the shortcut is the way in -->
data-relay-shortcut="Ctrl+Shift+K" <!-- instead of Alt+Shift+B, on this surface -->

Changing your mind without a deploy

The same choices are on the Install page, per surface: floating with its corner and offsets, an inline trigger with the snippet to copy, or the shortcut alone. 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="inline", "shortcut" or "off" on your tag stays without our button 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 much it matters

When the way in did not already name a type, the panel asks first: “What’s this about?” Something’s broken, I have an idea, and I have a question. Picking one sets the type and opens the form in that kind’s words. A trigger that names a type, open({ type }), or data-relay-default-type skips the question. A type your workspace added is asked in a problem’s words.

Each kind then asks about impact in its own words. A problem asks how much it is affecting them (Blocking me, Slowing me down, Minor). An idea asks how much it would help (A lot, Somewhat, Nice to have). A question asks whether they are stuck (Yes, I’m stuck, or No, just curious). Routing rules can send each type somewhere of its own. The answer becomes the priority in Linear: Blocking me is High, A lot and Yes, I’m stuck are Medium, and Nice to have and No, just curious have no priority. Nothing a reporter picks is Urgent.

A button that is always about one kind of report can start with it chosen. 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

Serving the client from your own origin
Do not copy the files. relay.js is an ES module and is always fetched in CORS mode; this deployment serves it with the header that allows that. A copy without it gives you a blocked request, no error anyone reads, and a button that never appears — while the classic recorder.js beside it keeps working, which makes it look like a Relay bug.
Expecting the session to travel
The intake answers CORS without Allow-Credentials, deliberately, so a browser will not attach this deployment's cookie to your cross-origin press. The key is the credential on that path. A keyed report therefore arrives with no reporter unless your page says who it is, which is what identify() is for.
A fixed-height app shell
If your layout is a shell at 100dvh with content in overflow:auto panes — every chat, inbox, dashboard and IDE — then document.scrollHeight is the fold. Relay expands those panes before the shutter and puts them back, and the browser test asserts the scroll position is unchanged afterwards. You do not have to do anything; this is here so the picture being taller than the window is not a surprise.
Reading the screenshot as a screen grab
It is not one. A clipped shadow, a substituted font or a gray box where a cross-origin image was is a rasterization artifact, not the bug. Form values and scroll positions were copied across deliberately, so what the picture shows in a field is what was in that field. Every brief says this, in those words.

A masked screenshot

Strict paints the picture over in the reporter’s browser, before it leaves the page, except elements marked data-relay-allow. A gray box is something kept back on purpose. It is not the bug, and it is not a part of the page that failed to draw.

Could not save the transfer.
Gray boxes are everything the product has not marked data-relay-allow. They are kept back on purpose, not the bug. The sentence on the white row is the only page text a fixer is meant to read from the picture.

Mark the one thing a fixer needs and that holds nothing private, such as an error banner:

<div role="alert" data-relay-allow>Could not save the transfer.</div>

data-relay-mask inside an allowed element still wins. Backgrounds and borders stay, so the picture still shows where things were. Password fields are empty either way.

Linear, and what Strict sends

Strict turns on a thin hand-off, among its other capture rules. A Linear issue then carries what the reporter wrote, what they pointed at, the type and severity, the route with its query string removed, the masked screenshot when this workspace takes one, and a link back to the full report in Relay. The ordered timeline, network requests and their correlation ids, the console, the build, the browser and viewport, the platform, page text beyond what Strict capture keeps, and who reported it stay on the report in Relay.

Turn Strict, or the minimal hand-off switch, off and Linear and Cursor receive the evidence an agent can use: that timeline, failed requests and their ids when capture kept them, the console, the build, the browser and viewport, the address, what they wrote and pointed at, the screenshot this workspace takes, and the same link back. GitHub, Claude and a webhook are not given a different pack by that switch.

Passwords and identity tokens never go out, at any privacy level. Personal data that capture and redaction have already removed does not come back in a hand-off. Values typed into fields are left out of what Linear and Cursor receive. A full screenshot can still show the screen. Strict masks that picture and does not read those values. The issue’s “What to do” only refers to sections that issue actually contains, and tells the reader to open the report in Relay when the timeline was left out.

Privacy & security lists what will and will not go to Linear when Linear is connected and Strict, or minimal hand-off, is on.

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.

Create a workspace

Reports are scrubbed on the server before they are written down. Password fields are never captured.