Fruitback

Installing Fruitback

Two things have to exist: the worker, which holds the Linear key, and the widget, which goes on the site you want feedback about. Neither is useful alone — the widget has nowhere to write, and the worker has nobody writing to it.

Do the worker first. The widget needs its URL.

Which mode you are installing changes step 3 and nothing else. Steps 1, 2, 4, 5 and 6 are the worker’s, and every mode needs them. Step 3 is where public mode puts the widget on the page, team mode ships it dormant, and private mode ships nothing at all — there the extension mounts it, and the reviewer’s side is reviewing.md. modes.md is the page that picks between the three, and it is worth reading first: who may read is read — FRUITBACK_READ in the worker’s environment at step 2, or per client in the map at step 4 — and the mode decides who can satisfy it. A public-mode site can, by minting the identity tokens of step 5; team mode is the one where the reviewer supplies the credential and the page never holds it; private mode can do neither.

The packages are not on npm yet. Everything below describes the shape of the install; the npm i lines will work once the first release is published. Until then, the <script> route works from a file you host yourself — pnpm --filter @fruitback/widget build produces it.


1. Linear

You need two values and a key.

The API key — a personal API key from your Linear settings. It is the only real secret here, and the entire reason the worker exists: it must never reach a browser.

The team, and optionally the project. Both are UUIDs. The quickest way to read them is to ask Linear:

curl -s https://api.linear.app/graphql \
  -H "Authorization: $LINEAR_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ teams { nodes { id key name } } }"}'

Swap teams for projects for the other one. A project is optional: without it, issues are created in the team’s default.

Labels need no setup. Fruitback creates fruitback — and fruitback:<clientId> when a client is named — on demand, on first use. A label that cannot be created is dropped and the feedback still goes through: losing a label is a triage annoyance, losing someone’s note is a bug.


2. The worker

It is a single Node process in a container. .env.example lists every variable docker-compose.yml reads, with the reasoning next to each: the worker’s own, and the two that choose the image and the host port.

The minimum, with the seeds in Linear:

FRUITBACK_STORE=linear
LINEAR_API_KEY=lin_api_…
LINEAR_TEAM_ID=…
ALLOWED_ORIGINS=https://staging.acme.test

With SQLite, which is the default of docker-compose.yml, ALLOWED_ORIGINS is the only line to set.

Locally

From the sources, with no container and no .env. The command keeps running, so use another terminal for anything else:

pnpm --filter @fruitback/worker dev:fake   # node --watch, in-memory store

In a container, with the image built from this checkout:

cp .env.example .env                  # then set ALLOWED_ORIGINS
docker build -f apps/worker/Dockerfile -t ghcr.io/sakuga-software/fruitback-worker:edge .
docker compose up -d --wait           # the image you just built

To try the whole loop with no Linear account at all, pnpm dev runs the worker against an in-memory Linear and serves the playground next to it. Nothing is written to anyone’s workspace.

On a server

docs/self-hosting.md is the guide: the reverse proxy, every variable, backups, upgrades, and what to check when something is wrong. Whatever you deploy with, two things matter:

Check both before going further:

curl https://feedback.acme.dev/health
# {"ok":true,"store":"sqlite","openRead":1}
curl 'https://feedback.acme.dev/feedback?url=https%3A%2F%2Fstaging.acme.test%2F'
# {"url":"https://staging.acme.test/","issues":[]}

store is the store the worker runs on. openRead counts the clients whose pins anyone can read, and is absent when there are none. With FRUITBACK_CLIENTS set, add &client=<id> to the read. The read answers 401 when the client’s policy is authenticated and no token is sent, which is expected. That policy is the client’s own read when the client sets one, and FRUITBACK_READ when it does not.


3. The widget

A script tag

For a site with no build step. endpoint and client are required and are the whole configuration; label is optional.

<script
  src="https://cdn.acme.dev/fruitback.iife.js"
  data-fruitback-endpoint="https://feedback.acme.dev"
  data-fruitback-client="acme"
  data-fruitback-label="Leave feedback"
  defer
></script>

Add data-fruitback-include-env="true" to send the reporter’s user agent, language and platform with each note. It is off by default, and privacy.md says what that means for your notice.

defer matters: the widget mounts into <body>. It auto-mounts only when both endpoint and client are on the tag; with either missing it does nothing and Fruitback.init(…) is yours to call — which is what a site with its own bootstrap wants.

An import

npm i fruitback
import { init } from 'fruitback';

const widget = init({
  endpoint: 'https://feedback.acme.dev',
  clientId: 'acme',
});

In React, mount it in an effect — the widget points at elements your app has rendered, so it has to arrive after they do:

useEffect(() => {
  const widget = init({ endpoint: WORKER_URL, clientId: 'acme' });

  return () => widget.destroy();
}, []);

init is browser-only and says so if called while server-rendering, rather than failing somewhere inside a bundle.

What else init takes

Option Why you would
label the text on the floating button
locale the language to show, as a tag like fr or pt-BR — the browser’s by default
messages your own words for that language — see Another language
ignore elements the pointer must skip — your own chrome, a support chat, a cookie banner
identityToken a function returning a signed token, so a reporter is verified rather than claimed
includeEnv true to send the reporter’s user agent, language and platform — off by default
transport who carries the calls — the extension’s, in team mode below

The feedback of a page, as text

The settings panel has a button, Copy the feedback as text. It puts the notes of the page in the clipboard, as Markdown: one block per note, with its status, the element it points at, the component and the file behind it when your build exposes them, who wrote it, and the team’s replies.

# Feedback on https://staging.acme.dev/pricing

## 1. FB-12 — To do

- Element: button "Add to cart" · `[data-testid="card-latte"] .add`
- Component: Button · src/components/site.tsx:42
- By: Camille Durand · 2026-10-06

> The price is cut off on mobile

Reply from Léa · 2026-10-07

> Fixed in the next deploy.

Another language

The widget ships English and French. It follows the browser’s language, locale overrides it, and messages supplies or overrides the words:

init({
  endpoint: 'https://feedback.acme.dev',
  clientId: 'acme',
  messages: {
    de: {
      'launch.label': 'Feedback geben',
      'settings.open': 'Fruitback-Einstellungen öffnen',
      'settings.dialog': 'Fruitback-Einstellungen',
      'orphans.count': { one: '{count} Notiz ohne Element', other: '{count} Notizen ohne Element' },
    },
  },
});

To add a language to the bundle, so every site gets it, see translating.md.

Team mode: dormant until a reviewer arrives

Ship the widget and call init only when a reviewer with the extension opens the page. Your users see nothing; your reviewers see their pins.

import { init, type FruitbackTransport } from 'fruitback';

// The extension puts this on the page. It is not part of the package, so your project declares it.
declare global {
  interface Window {
    fruitbackExtension?: { version: number; transport: FruitbackTransport };
  }
}

let widget: ReturnType<typeof init> | undefined;

const sync = () => {
  const extension = window.fruitbackExtension;

  // Gone: the reviewer switched this site off, or to private mode. The extension cannot destroy a
  // widget your site owns, so it tells you and you do.
  if (extension === undefined) {
    widget?.destroy();
    widget = undefined;

    return;
  }

  if (widget !== undefined) return;

  widget = init({
    endpoint: 'https://feedback.acme.dev',
    clientId: 'acme',
    // Every call goes through the extension, which attaches the reviewer's session.
    transport: extension.transport,
  });
};

window.addEventListener('fruitback:extension', sync);
sync();

Both halves are needed: the event for a page that loaded before the extension announced itself, the call for one that loaded after. The same event fires when the extension withdraws, so sync reads the property rather than assuming an arrival — and it is written so a second announcement cannot mount a second widget.

The reviewer then turns your origin on in the extension’s popup, in Team mode, and pairs with the worker. Until they pair, the extension relays nothing — see the threat model for why, and for the five other things the relay checks first.

This mode is worth turning on only with FRUITBACK_READ=authenticated. Otherwise the same pins are readable by anyone who can build the URL, and all it buys is a page your users do not see the widget on.



4. One worker, several sites

Set FRUITBACK_CLIENTS and each site routes to its own team, project and labels:

{
  "acme": { "teamId": "team_…", "projectId": "proj_…", "origins": ["https://acme.test"] },
  "globex": { "teamId": "team_…" }
}

Once it is set, a client has to be named on both paths — client= on a read, seed.client.id on a write — and an unknown one is refused rather than served from the default. On a shared worker, falling back to the default team is how one client reads another’s feedback.

origins binds a client to the sites it may be embedded on. It is not authentication: clientId is asserted by the browser. It is the same trust level CORS gives, and strictly more than nothing.


5. Identified reporters, optionally

By default a reporter is anonymous, and a name typed into the popover is stored as a claim — Linear shows it as self-declared. A site that already knows who its visitor is can say so properly: share a secret with the worker, mint a short-lived JWT, and hand it to the widget.

init({
  endpoint: WORKER_URL,
  clientId: 'acme',
  // Called before every write, so a token that expired mid-session is refreshed rather than refused.
  identityToken: () => fetch('/api/fruitback-token').then((response) => response.text()),
});

The token is an HS256 JWT with sub, an exp, and optionally name and email, signed with the client’s identitySecret. It travels in the Authorization header and never inside the seed — the seed is stored verbatim in an issue description that anyone with workspace access can read.

A token that fails to verify is a 401, not a silent downgrade to anonymous: a site that meant to identify someone and got it wrong should hear about it.


6. What the team sees

A note becomes a Linear issue titled with the reporter’s own words, carrying the CSS selector, the React component and the source file. Replying in the issue puts the reply back inside the pin on the page — that is the loop closing, and it needs nothing configured.

If your team treats issue comments as internal, turn that off: FRUITBACK_HIDE_COMMENTS=1, or "showComments": false on one client. The read path needs no authentication, so anything it returns is readable by anyone who can load the page.