loupfeedFeedback

Feedback that lands on a line of code.

A user long-presses the thing that is wrong and types a sentence. What reaches you is not a screenshot and a guess — it is that exact element, resolved to src/file.tsx:54, with the session that led there and everything your app knew at the time.

React, Vue, Svelte, React Native and Expo · npm i @loupfeed/react

How it works

Four steps, and none of them are yours.

Your users do the first one. The other three happen on their own.

1

They point at it

Long-press any element in the running app, or open the composer from your own button. The pin sticks to that element.

2

Context attaches

The element's identity, the route, the release, your app context, the breadcrumb trail, and the last seconds of replay.

3

The id travels alone

Only an opaque hash leaves the browser. The map from hash to source stays on your server.

4

You get file and line

The dashboard — or your agent over MCP — resolves it against the manifest for that exact release.

The anchoring

Anchored to the element, not to a screenshot.

Most feedback tools pin a comment to x/y coordinates on a page, which stops meaning anything the moment the layout changes. loupfeed tags every element at build time with an opaque, deterministic id and pins the thread to that.

  • The id is a hash of path, line and column — stable for the whole release, distinct for two elements on the same line
  • A build plugin accumulates id → src:line into a manifest, keyed by version@commit
  • The manifest is uploaded to your ingest at build time and never published with your app
  • Feedback from an old release still resolves, because it carries its own release and matches its own manifest

If the plugin is not installed, the SDK falls back to a root-relative CSS path so feedback still works — you just do not get the jump to source.

// in your shipped bundle — this is all there is <button data-fb="fb0a1b2c3d4e">Export CSV</button> // server-side only: fb-manifest.1.4.2@9af31c2.json { "release": "1.4.2@9af31c2", "ids": { "fb0a1b2c3d4e": "src/pages/Checkout.tsx:54", "fb9f8e7d6c5b": "src/Header.tsx:12" } }
The privacy invariant

Your source paths never reach the client.

This is not a setting. Runtime packages never import the build packages — the two halves meet only through the data-fb attribute and the manifest format. CI greps the built bundles for src/ and .tsx: and fails the build on a hit.

Enforced in CI

Run pnpm check:privacy yourself — it is the same check that guards every release of loupfeed.

Resolution is authenticated

Turning an id back into a path happens behind your ingest API, on infrastructure you control.

Last-chance scrub

beforeSend hands you the whole event before it leaves the device, to redact or drop.

The thread

A conversation, not a ticket queue.

Every pin opens a thread on that element. You reply from the dashboard or straight from your agent, and the reporter sees the answer inside the app they reported from — not in an email they will not open.

  • Threads carry status: open, resolved, wontfix
  • Identify a reporter by id, email, phone or name, scoped per tenant
  • Filter the inbox by status, route or free text
M
Mara · just now · /checkout
“This button does nothing on Safari”
◎ src/pages/Checkout.tsx:54
L
You · 4 min later
Fixed — the download path was broken on Safari. Ships today, thanks for catching it.
✓ resolved
Mobile

The same thing, inside your app.

React Native has no DOM, so the opaque id rides as an fbId prop threaded onto the native view's testID. The inspector hit-tests a touch with measureInWindow — the native analogue of the web overlay — and pins feedback to the nearest tagged view.

  • Built and verified on the iOS simulator, Expo SDK 52, New Architecture
  • Uses only core React Native APIs — no custom native module, runs in Expo Go
  • The Metro/Babel tagger keeps the same server-side manifest model as web
Install

Two lines to capture, one plugin to resolve.

// 1 · the SDK import { init, setUser, setContext } from '@loupfeed/react'; init({ dsn: 'https://…@your-ingest/o/acme/p/web', replaysSampleRate: 1 }); setUser({ id: 'u_42', plan: 'pro' }); setContext('account', { id: 'acme', seats: 50 }); // 2 · the build plugin — tags elements, uploads the manifest import { loupfeedVite } from '@loupfeed/vite-plugin'; export default defineConfig({ plugins: [loupfeedVite()] });

The adapter mounts the overlay in a shadow DOM, so nothing of yours is restyled and nothing of ours leaks into your CSS. Pick the plugin that matches your bundler — they all share one tagger and one manifest format.

LayerPackages
Web adapters@loupfeed/react, /vue, /svelte
Mobile@loupfeed/react-native, /expo-inspector
Bundlersvite-plugin, webpack-plugin, rollup-plugin, esbuild-plugin
Compilersbabel-plugin (Next, Metro), svelte-preprocess, vue-plugin
With your own AI

Your agent reads the thread and closes it.

Feedback is exposed through @loupfeed/mcp, so the agent you already use picks it up with everything attached — the resolved source line, the contexts, the breadcrumbs and a readable replay — then replies to the reporter and sets the status itself.

list_feedbackget_feedbackresolve_sourcereply_to_threadset_feedback_status
# claude, with @loupfeed/mcp connected list_feedback → 12 open · newest: “broken on Safari” get_feedback → src/pages/Checkout.tsx:54 + context …agent edits Checkout.tsx… reply_to_thread “fixed — ships today” set_feedback_status → resolved
The rest of the platform

Feedback is one of four.

Put a pin in it.

Free to self-host, forever, and source-available so you can read every line before you trust it with your users.