# Install Three env values, one route behind your sign-in, one tag on signed-in pages. Ten minutes. The console tells you when the first report lands. Shutterbug only accepts reports from signed-in people, so every host proves who is reporting: your server presents the project secret once and gets a short-lived token the widget carries. That is the route. Everything else is a tag. ## Before you start 1. Sign up at [shutterbug.dev](/sign-up). A workspace and a first project are created on the first visit. 2. Open [Install](/install). For another project, press New project: a name and the origins the widget will run on, one per line. The project's secret is shown once, on that page. 3. Keep the Install page open. It flips when the token route answers and again when the first report lands. List every origin the widget runs on, one per line, including `http://localhost:3000` for development; a project can list up to ten. A capture from an origin that is not listed is refused. ## 1. Env Three values from the Install page: ```env title=".env" SHUTTERBUG_URL=https://shutterbug.dev SHUTTERBUG_PROJECT_KEY=pk_… SHUTTERBUG_PROJECT_SECRET= ``` The key is public and sits in the tag. The secret is a server secret: put it in the deployment's environment, never in the repository. ## 2. Token route Behind your own sign-in. Whoever this returns 200 to can report. ### Next.js with Clerk `app/api/shutterbug/token/route.ts`: ```ts title="app/api/shutterbug/token/route.ts" import { auth, currentUser } from "@clerk/nextjs/server"; export async function GET() { const { userId } = await auth(); if (userId === null) { return Response.json({ error: "sign_in_required" }, { status: 401 }); } const user = await currentUser(); const url = process.env.SHUTTERBUG_URL + "/api/reporter-tokens"; const upstream = await fetch(url, { method: "POST", headers: { authorization: "Bearer " + process.env.SHUTTERBUG_PROJECT_SECRET, "content-type": "application/json", }, body: JSON.stringify({ projectKey: process.env.SHUTTERBUG_PROJECT_KEY, reporter: { id: userId, email: user?.primaryEmailAddress?.emailAddress ?? null, }, }), cache: "no-store", }); return new Response(upstream.body, { status: upstream.status, headers: { "content-type": "application/json", "cache-control": "no-store", }, }); } ``` ### Next.js with next-auth The same route with the session from `auth()`: ```ts title="app/api/shutterbug/token/route.ts" import { auth } from "@/auth"; export async function GET() { const session = await auth(); const user = session?.user; if (!user?.id) { return Response.json({ error: "sign_in_required" }, { status: 401 }); } const url = process.env.SHUTTERBUG_URL + "/api/reporter-tokens"; const upstream = await fetch(url, { method: "POST", headers: { authorization: "Bearer " + process.env.SHUTTERBUG_PROJECT_SECRET, "content-type": "application/json", }, body: JSON.stringify({ projectKey: process.env.SHUTTERBUG_PROJECT_KEY, reporter: { id: user.id, email: user.email ?? null, }, }), cache: "no-store", }); return new Response(upstream.body, { status: upstream.status, headers: { "content-type": "application/json", "cache-control": "no-store", }, }); } ``` To let only some people report (staff, say), add one `if` on the role your session already resolves and return 401 for the rest. Shutterbug never knows who was refused. If a proxy guards every route (next-auth's `authorized` callback, Clerk's `clerkMiddleware` with `auth.protect()`), open this one path in it and let the route refuse for itself. A signed-out widget needs the route's own 401: a redirect to your sign-in page comes back as a 200 HTML document, which the widget reads as a server fault rather than *sign in*, and **Check the route** reads as *answered 307*. ### Any other stack The route is one POST. [Token exchange](/docs/token-exchange) has the contract and the route in Node, Laravel, Rails and ASP.NET. Deploy it, then press **Check the route** on the Install page. It answers *Route found, behind sign-in* when a request with no session gets a 401, and says what is wrong otherwise. ## 3. Tag On signed-in pages only, so the mark is never shown to someone who cannot report. ### Next.js In the layout that only signed-in people see: ```tsx title="app/(app)/layout.tsx" import Script from "next/script"; ``` `data-shutterbug-release` is the build id, so every report says which build it was made on. On Vercel that is `VERCEL_GIT_COMMIT_SHA`. A container or standalone build has no such variable, so pass whatever your own build writes (`BUILD_SHA`, say); without it every report says the same build. A `` in the head works too. Every other attribute is in [The widget](/docs/widget). The loader is 3.5 KB gzipped with no dependencies. It draws nothing into your DOM: the mark and the bar live in a closed shadow root. ## 4. First report Open the app signed in. The Install page flips to *Token route answered*. Press the mark in the corner, or Alt+S, point at something, write a line, send. The page flips to *#001 logged* with a link to the report. Installed. ## If something is off - **The mark does not appear.** The tag is on a page before sign-in, the token route path in the tag is wrong, or the loader was blocked. Check the browser's network tab for `loader.js` and `/api/shutterbug/token`. - **The token route answers 401 to a signed-in person.** The route is not reading the same session as the page, or the secret is wrong. Rotate it on Settings and set it again. - **Send fails with a network error.** The app's origin is not listed for this project, or is listed with a different scheme or port. Origins are compared exactly: `https://app.example.com`, not `app.example.com/`. - **The widget says *This project is no longer taking reports.*** The project is archived: ingest and the token exchange answer 410. Restore it on the project's Settings; the key and the secret are unchanged. - **Check the route says unprotected.** The route returned 200 to a request with no session. Put it behind your sign-in before anyone finds it. ## For agents Give your agent [this file](/docs/install.md) and the values from the Install page. The page's **Copy for your agent** button puts a prompt with the public values on the clipboard; the secret is never in it. --- # The widget One script tag. The loader is 3,698 bytes gzipped, inside a hard budget of 3,712, with no dependencies and draws nothing into the host's DOM; the mark and the bar live in a closed shadow root. The rest (about 56 KB gzipped) loads once: two seconds after the loader runs, at once on a page where a *See it in the app* walk is in progress, or sooner when someone hovers or presses the mark or presses the keyboard shortcut, whichever comes first. ## The tag ```html ``` | Attribute | Values | Default | What it does | |---|---|---|---| | `data-shutterbug-project-key` | `pk_…` | required | The project's key from the console. Public. | | `data-shutterbug-endpoint` | URL | required | Where reports go. `https://shutterbug.dev`. | | `data-shutterbug-token-url` | same-origin path | none | The host's token route. Required on any origin other than shutterbug.dev. | | `data-shutterbug-release` | text | from ``, else `unknown` | The build id every report carries. Append ` dirty` for an uncommitted build. | | `data-shutterbug-launcher` | `mark`, `none` | `mark` | `none` hides the mark; open the widget from your own control with `window.shutterbug.open()`. | | `data-shutterbug-position` | `bottom-right`, `bottom-left` | `bottom-right` | Which corner the mark sits in. | | `data-shutterbug-offset` | `x,y` in px | `0,0` | Nudge the mark away from the corner. | | `data-shutterbug-theme` | `light`, `dark`, `auto` | `auto` | Force a theme. `auto` follows your page: the `color-scheme` on its root when that is `light` or `dark`, the system's setting when it is `light dark`, otherwise how dark the first opaque background from `body` up is, and light when nothing paints one, since the page under it is then white. It reads your page again when the system's setting changes or your root or `body` changes its attributes, as a theme switch does, and writes nothing to it. The mark the loader draws before the widget loads is light unless you pass `dark`. | | `data-shutterbug-hotkey` | a combination, or `false` | `Alt+S` | The keyboard shortcut. `false` disables it. Ignored while typing in a field. | | `data-shutterbug-allow-origins` | comma-separated origins | the page's own origin | Which requests are recorded in the network tail. Requests to shutterbug.dev are never recorded. | | `data-shutterbug-mask-selectors` | comma-separated CSS selectors | none | Elements masked in the screenshot, on top of the fields masked always. | | `data-shutterbug-shared-reporter` | present, or `true` (`false` turns it off) | off | Your token route sends one reporter id for everyone, so *Your reports* is titled *Reports on this app* and lists everyone's reports. See *Your reports* below. | | `data-shutterbug-mask` | on any element | none | Mask this element in the screenshot. Password fields, and fields whose `autocomplete` is a card, code or password token, are masked always. | ## The API The loader sets `window.shutterbug`: - `open()` opens the bar (loads the widget on first use). If a draft is kept, it reopens that draft as it was left. - `close()` closes the bar, as `Esc` does. A draft holding something the person made (a note, a picked element, the screenshot left out) is kept behind the mark until they send it or discard it; see *Sending a report* below. - `destroy()` removes the mark, the listeners and the buffers, and ends any draft: one waiting for the connection is not sent, and one already sending may still arrive, with no receipt. ## The host element and your shortcuts The widget lives in one `div` with the attribute `data-shutterbug-host`, appended to `body`: the loader's mark first, then the widget itself once it loads. Its shadow root is closed, so your code cannot see inside it. The attribute is a stable contract: it stays on the element that holds the widget, whatever else about the widget changes. While the bar is open, keys typed into it stop at its shadow root, after the widget has handled them. A shortcut handler on `window` or `document` in the bubble phase (the default for `addEventListener`, and how most hotkey libraries listen) never sees them, so typing a note cannot fire your shortcuts. A handler that listens in the capture phase still sees those keys, before the widget does, and its `event.target` is the host `div`, not the note. Such a handler should ignore events from inside the widget: ```js window.addEventListener("keydown", (event) => { const target = event.target; if (target instanceof Element && target.closest("[data-shutterbug-host]")) return; // your shortcuts }, true); ``` The same check covers the mark. When the bar closes, the mark takes the focus back, and a key pressed on it reaches your listeners in either phase, with the host `div` as the target, whether or not a draft is kept behind it; so does a key pressed on the loader's mark before the widget has loaded. Keys typed in the receipt that replaces the bar after a send stop there too; the widget's own shortcut still works there and starts the next report. ## Content Security Policy The widget runs under a strict policy. Its styles need no nonce: they are constructed stylesheets adopted into its own shadow roots, so `style-src 'self'` is enough, and nothing it draws carries a `style` attribute. Allow three things: - `script-src`: the origin you load `loader.js` from. A policy with a nonce and `'strict-dynamic'` ignores the origins listed in `script-src`, so there the loader's tag needs the page's nonce instead (`nonce="…"` on the tag, or the `nonce` prop on `next/script`, shown in [Install](/docs/install)). The loader loads the rest of the widget itself, which `'strict-dynamic'` allows. - `connect-src`: the endpoint origin, where reports go, and the origins of the web fonts and images the page uses (`'self'` for its own, served with CORS for the rest), or the screenshot draws without them. For fonts from a font service that means two origins: the stylesheet's (`fonts.googleapis.com`), because the screenshot reads the sheet's text to find the font files, and the files' (`fonts.gstatic.com`). A fetch the policy refuses also files a violation report, so a missing origin shows up in your CSP reports on every capture. - `img-src`: `data:`. The screenshot is drawn from a `data:` image; without it the widget says it couldn't capture the screen and still sends the note. Trusted Types (`require-trusted-types-for 'script'`) is not supported: the loader and the widget write their icons with `innerHTML`, which such a policy refuses, so the mark never appears. The widget needs Chrome 100, Firefox 101 or Safari 16.4 or later. It is built on constructed stylesheets; a browser without them gets no widget, because the loader checks and stays off. ## Sending a report Pressing the mark, or the shortcut, opens the bar and takes a screenshot of the page as it is. Nothing is drawn into your page. **The screenshot** fills the top of the bar at a size that can be read, and opens at full size on a click. *Point at what's wrong* lets the person pick an element: the bar steps aside for a small bar in its place (*Click what's wrong*, with *Cancel*), and once they click, the screenshot is taken again around that element and the outline moves from the page into it. With nothing picked, one press takes the screenshot again. **Sent with it** is one sentence under the note that says what goes with the report, in plain words and only what is there: the page, the screenshot, what they picked, their recent steps (a press on a control or a route change) and technical details from their browser (the requests, console lines, browser and viewport). Nothing in it can be removed: requests, console lines and steps always go, redacted as below. The screenshot is the one thing a person can leave out, with *Remove* on its frame; *Put it back* restores it until they send, and *Take one* takes a fresh one. **A kept draft.** Closing the bar keeps what the person made: the note, the picked element, and the screenshot or that they left it out. The mark then reads *Finish your report*, and pressing it brings the draft back as it was, without a new screenshot. *Discard* ends a draft, after asking. A draft nobody touched ends when the bar closes. A draft lasts until the page loads again, except a note kept across a sign-in (below). Sent from another page or later, it still carries the page, viewport and element its screenshot was taken with, and the bar says *Taken on* that page. **Offline.** A send that fails because the browser is offline is kept, and the bar says it sends itself when the connection is back; meanwhile *Send* reads *Send now*, and behind the closed bar the mark reads *Sends when you're back*. The report is sent again without a press each time the connection returns; a failure of any other kind waits for the person to press *Send*. It carries the same draft id, so it cannot be filed twice. **A note across a sign-in.** When a send is refused because the person's session expired, the bar says the note is kept, and the note's words (only the words: no screenshot, no picked element) are kept in the tab's session storage (below) while they sign in again. Any page load in that tab within 30 minutes brings them back as a kept draft with a fresh screenshot, so your sign-in page can load the widget without spending them. They go when that draft is sent or discarded, or after 30 minutes. **The receipt.** After a send the bar settles into a receipt (*Logged as #041.*) with *See it*, which opens the report in *Your reports*. It closes itself after eight seconds, or waits while the pointer or the keyboard focus is on it. ## Your reports A reporter can open the mark and see what they have raised, answer what the team asked, add to a report that is still open, read what the team did about the rest, open a fix in your app, and send it back if it isn't right, without leaving your app. Nobody is asked to confirm anything. **The dot.** When a report needs the reporter (the team asked a question) and has moved since they last opened *Your reports*, the mark carries a small dot. It has no number, and a fix being done does not light it. Hovering the mark shows a second line, such as `1 needs you · 4 done since you looked`, which opens *Your reports*; on a touch screen the bar's header has a *Your reports* link. **The sheet.** It opens from the mark's side of the page, 736 px wide, and leaves your app in view; on a phone it comes up from the bottom. Reports are grouped as *Needs you* (the team asked a question), *Done*, *With the team*, and *Closed*, which is collapsed to a count. A row of buttons at the top jumps to each group. *Done* is news: the newest first, each row with the line the team wrote saying what changed, and an *Earlier* divider between what was done since the reporter last opened the sheet and the rest. *Done* and *Closed* together show at most 200 rows, the oldest *Closed* first; *Needs you* and *With the team* are never cut. Any report opens inside the sheet, with the screenshot and the picked element outlined, the reporter's note and the lines the team wrote for them, and previous and next through its group. The team's internal notes and the trail never appear. **Answering, adding, sending back.** Under *Needs you* each report has an answer box, up to 500 characters. Under *With the team* a report has *Add something*, which opens a box for anything the reporter wants to add to it, up to 500 characters; the team sees it as *replied*. Under *Done* a report has *See it in the app* and, under it, *Not fixed? Send it back*, which opens a box labelled *What's still wrong?*. It needs words, up to 500 characters, and sends the report back to the team as to do. A report that is done is done: no reply reopens it by accident, and a closed report takes nothing. **See it in the app.** *See it in the app*, on a done report, walks the reporter through the *Done* reports in your app, page by page. A card at the corner shows the report's screenshot from when it was filed, beside your page as it is now: each step goes to the report's page, scrolls to the element they picked and outlines it, and shows the team's line. There are no verdicts to give; *Not fixed? Send it back* is there if the fix isn't right, and it sends words. Previous and next move through the reports, nothing advances on its own, and *Your reports* on the card ends the walk and opens the sheet. A report the team reopened or archived since the list loaded is skipped, and the next step says so (*#022 changed since the list loaded.*). The mark and the sheet step aside while the card shows. The card keeps clear of the element it asks about: on a wide screen it takes the side that covers it least, with a smaller screenshot when neither side clears it, and on a phone it moves to the top edge when the bottom would cover it; when it cannot clear it, it says the element is under the card, and to scroll your page only when scrolling can bring the element into view. **Your page's scroll.** The walk scrolls your page, and any scrolling container the element sits in, to bring each element into view, at once rather than smoothly whatever your `scroll-behavior` says, so it can tell what it moved. When the walk ends, or the widget is destroyed, it puts back every offset it moved, the window's included, as they were before its first scroll; a pane the reporter scrolled themselves during the walk is left where they put it. ### Who sees what *Your reports* lists the reports filed under the reporter id your [token route](/docs/token-exchange) sends. Give every person their own id and each sees their own reports. If your app sends one shared id for everyone, add `data-shutterbug-shared-reporter` to the tag (or `sharedReporter` on ``) and it is titled *Reports on this app* and lists everyone's. Without the attribute a shared id still works; the title says *Your reports*, which is wrong only in the title. ### What it costs your page The widget wakes two seconds after the loader runs and asks for the reporter's counts once per full page load, and again when the sheet opens and when it closes: one call to your token route and one small read from the endpoint. Navigations inside a single-page app cost nothing. Close the page before two seconds, or call `destroy()`, and the widget never loads. While a *See it in the app* walk is stored for the tab (see below) it wakes at once instead, so the card is back as soon as the next page loads. Screenshots in *Your reports* are resized on the server and framed on the element that was picked: a region around it, or the whole page when nothing was picked or the pick is large. They are fetched four at a time as they scroll into view. Opening one report, or a screenshot at full size, loads that screenshot as it was stored, up to 1,600 px on its longest edge. ### What it keeps in your origin's storage - `localStorage["shutterbug:seen:"]`: the last activity *Your reports* showed among the reports that need the reporter, so the dot clears. `` is the project key. It is per browser on purpose. - `localStorage["shutterbug:looked:"]`: the server's time from the last time the sheet opened, so the *Earlier* divider and the hover line's *done since you looked* count from it. It is the server's clock, not the browser's, and a browser with no key yet counts every done report once. - `sessionStorage["shutterbug:walk:"]`: a *See it in the app* walk in progress, so it can carry on after a page change. It holds the reports' ids and which step is shown, which reports this walk sent back, any words typed into *Not fixed? Send it back* and not sent yet, and the step being opened as the card shows it: its number, route, date, the first lines of the note (at most 300 characters) and the team's line, and, when it was already loaded, its screenshot as a `data:` image of tens of kilobytes with the outline of what was picked. It also holds where the pointer was when the reporter pressed, so the button under it still looks hovered on the next page, which side the card was on, and whether the next step owes the reporter a line (a report that changed since the list loaded). It is written as the walk moves, never on each keystroke, and when the page hides, so words typed into the send-back box survive a reload. It is cleared when the walk reaches its end. - `sessionStorage["shutterbug:note:"]`: a note's words, kept when a send is refused because the session expired, with the time they were kept, so the next load in the tab can restore them (see *A note across a sign-in*). It is removed when that draft is sent or discarded, or once it is more than 30 minutes old; `destroy()` leaves it. All four are read and written inside `try`. Without storage the widget still works: the dot shows whenever something needs the reporter, nothing is marked as new in *Done* and the hover line counts every done report, a walk offers each page as a link instead of going to it, and an expired session keeps the note only until the page reloads. ### See it in the app and your routes The walk follows only a path on your own origin, and goes to each report's page at most once. A page behind a redirect is offered to the reporter as a link instead of followed. A route the report recorded with a value masked out of its query (a parameter whose name looks like a token, a key or a code) is never followed: when the reporter is already on its path the walk treats it as that page, and otherwise it offers the path, without the query, as a link. A route kept in the URL's hash is recorded without the hash, so the walk reads it as the page the reporter is on when its path and query match that page's, and otherwise goes to its path. Anything that is not a path on your origin is never followed or linked; the card says the report's page can't be opened from here, and the reporter can still look at it where they are or move past it. While the reporter has a draft they put something into (words, a picked element, something left out of what is sent, or a send waiting for the connection), in the open bar or kept behind the mark, the walk never leaves the page on its own: the card says that opening the page drops the unsent report and offers *Finish your report*, which ends the walk and reopens the draft. If the element is not on the page any more the card says so, and when its text has changed it says *It may have changed.* ### Your reports and the CSP No new entries beyond *Content Security Policy* above. The screenshot is a `data:` image, which the section above already asks for, and the reads go to the endpoint origin your `connect-src` already lists. An answer or an addition the team's last move refused stays in the report as *You · not sent*, with a *Copy* that writes the words to the clipboard on the reporter's press; where the page cannot write to it (not a secure context, or a `Permissions-Policy` without `clipboard-write` around an embedded app) the button stays *Copy* and the words stay on screen to select. ## What a report carries - The note the person wrote, and the element they pointed at: its selector, its text, a short HTML snippet, and where it sat on the page. - The route and the build. The route, the viewport and the element are the ones the screenshot was taken with, even when a kept draft is sent from another page. - Every request on the allowed origins: method, URL, status, duration. For one that fails (status 400 or above), the first 2 KB of the response body, so the error is on the report; it is read from a copy after your code has the response, so a slow or endless error body never holds your code up. Never headers, never request bodies. Next.js router prefetches (the `?_rsc=` fetches behind every link in view) are left out unless they fail. A run of identical requests in a row (same method, URL and status), such as a Server Action a page sends every few seconds, is one row with a count, the latest duration and the slowest, so it cannot push everything else out of the last 50 rows. - The last 50 console lines, of every level (log, info, warnings and errors), each with its level. - The presses on links, buttons, fields and other controls, and the route changes, since the last report was sent, as selectors (the last 20). - The viewport, device pixel ratio and browser. - A screenshot of the page, or with an element picked, of the area around it (at least 640 × 360, drawn at twice the size), with the element outlined, masked before pixels exist. A video appears as its poster, or as an empty box of its size; the screenshot never loads the video again. Canvases are drawn until four million canvas pixels are in the screenshot; a canvas beyond that is drawn at a lower resolution, and once almost nothing is left, it is a dashed box marked *Canvas not captured*. The console shows all of it as a receipt. Over MCP, when that lands, the same receipt is a tool result. ## What never leaves the browser - The raw DOM. The screenshot is drawn in the browser from a copy that has already been masked; the copy is discarded. - Password fields, fields whose `autocomplete` contains `cc-number`, `cc-csc`, `cc-exp`, `cc-exp-month`, `cc-exp-year`, `one-time-code`, `current-password` or `new-password`, and anything under `data-shutterbug-mask` or `data-shutterbug-mask-selectors`, are masked in the copy before it is drawn. The page itself is never touched. - A password field with a show-password toggle turns into a text field when shown. Give it `autocomplete="current-password"` (or `new-password`) and it stays masked. - To turn the browser's autofill off, some libraries set or advise `autocomplete="new-password"` on comboboxes and search boxes (MUI's Autocomplete docs, among others). Those fields read as bullets in captures too. - Request headers, response headers, and request bodies. The network tail is method, URL, status and duration, plus the first 2 KB of the response body when a request fails. - Anything on origins not in `data-shutterbug-allow-origins`. The screenshot is a private blob, kept ninety days, readable from the console and by the person who filed the report, in *Your reports*. ## Who can report Only signed-in people. On shutterbug.dev the console's own session is enough; on any other origin the widget fetches a ten-minute token from the host's [token route](/docs/token-exchange) and carries it on every send. A report is refused when its origin is not listed for the project, or when the token is missing, expired, or for another project. --- # Token exchange Shutterbug accepts reports only from signed-in people, and only the host knows who is signed in. So the host runs one route, behind its own sign-in, that presents the project's secret to Shutterbug and hands the resulting token to the widget. The widget calls that route on the host's own origin, caches the token, and refreshes it thirty seconds before it expires. ## The contract ```http title="POST /api/reporter-tokens" POST {SHUTTERBUG_URL}/api/reporter-tokens authorization: Bearer {SHUTTERBUG_PROJECT_SECRET} content-type: application/json { "projectKey": "{SHUTTERBUG_PROJECT_KEY}", "reporter": { "id": "", "email": "" } } ``` | Status | Body | Meaning | |---|---|---| | 200 | `{ "token": "…", "expiresAt": "2026-09-14T12:10:00.000Z" }` | The widget token. Ten minutes. | | 400 | `{ "error": "invalid_fields", "issues": [...] }` | The body was not the shape above. | | 401 | `{ "error": "invalid_secret" }` | The secret is wrong, or was rotated more than 24 hours ago. | | 404 | `{ "error": "unknown_project" }` | No project has that key, or it was deleted. Pass it through: the widget says *This project isn't taking reports.* instead of offering a retry. | | 410 | `{ "error": "project_archived" }` | The project is archived and takes no reports. The secret is checked first, so this answer means the secret was right. Pass it through: the widget reads a 410 as closed and says *This project is no longer taking reports.* Restoring the project on its Settings opens it again, on the same key and secret. | The reporter's `id` is stored on every report as the host's own user id; the same id on two projects is two reporters. `email` is optional and may be null. ## The route the host writes Three rules, whatever the stack: it sits behind the host's sign-in and answers 401 to anyone else; it sends `cache-control: no-store`; it passes Shutterbug's status through. ### Node ```js title="app/api/shutterbug/token/route.js" export async function GET(request) { const user = await currentUserFrom(request); if (!user) { return Response.json({ error: "sign_in_required" }, { status: 401 }); } const url = process.env.SHUTTERBUG_URL + "/api/reporter-tokens"; const upstream = await fetch(url, { method: "POST", headers: { authorization: "Bearer " + process.env.SHUTTERBUG_PROJECT_SECRET, "content-type": "application/json", }, body: JSON.stringify({ projectKey: process.env.SHUTTERBUG_PROJECT_KEY, reporter: { id: user.id, email: user.email ?? null }, }), }); return new Response(upstream.body, { status: upstream.status, headers: { "content-type": "application/json", "cache-control": "no-store", }, }); } ``` ### Laravel ```php title="routes/web.php" Route::middleware('auth') ->get('/api/shutterbug/token', function (Request $request) { $user = $request->user(); $url = config('services.shutterbug.url') . '/api/reporter-tokens'; $upstream = Http::withToken(config('services.shutterbug.secret')) ->post($url, [ 'projectKey' => config('services.shutterbug.key'), 'reporter' => [ 'id' => (string) $user->id, 'email' => $user->email, ], ]); return response($upstream->body(), $upstream->status()) ->header('Content-Type', 'application/json') ->header('Cache-Control', 'no-store'); }); ``` ### Rails ```ruby title="app/controllers/shutterbug_tokens_controller.rb" class ShutterbugTokensController < ApplicationController before_action :authenticate_user! def show secret = ENV.fetch("SHUTTERBUG_PROJECT_SECRET") url = URI("#{ENV.fetch("SHUTTERBUG_URL")}/api/reporter-tokens") body = { projectKey: ENV.fetch("SHUTTERBUG_PROJECT_KEY"), reporter: { id: current_user.id.to_s, email: current_user.email } }.to_json upstream = Net::HTTP.post( url, body, "Authorization" => "Bearer #{secret}", "Content-Type" => "application/json" ) response.headers["Cache-Control"] = "no-store" render json: upstream.body, status: upstream.code.to_i end end ``` with `get "/api/shutterbug/token", to: "shutterbug_tokens#show"` in `routes.rb`. ### ASP.NET Core ```csharp title="Program.cs" app.MapGet("/api/shutterbug/token", async ( HttpContext http, IHttpClientFactory clients, IConfiguration config) => { var user = http.User; if (user.Identity?.IsAuthenticated != true) { var refused = new { error = "sign_in_required" }; return Results.Json(refused, statusCode: 401); } var client = clients.CreateClient(); var secret = config["SHUTTERBUG_PROJECT_SECRET"]; client.DefaultRequestHeaders.Authorization = new("Bearer", secret); var url = config["SHUTTERBUG_URL"] + "/api/reporter-tokens"; var upstream = await client.PostAsJsonAsync(url, new { projectKey = config["SHUTTERBUG_PROJECT_KEY"], reporter = new { id = user.FindFirst(ClaimTypes.NameIdentifier)?.Value, email = user.FindFirst(ClaimTypes.Email)?.Value, }, }); http.Response.Headers.CacheControl = "no-store"; var body = await upstream.Content.ReadAsStringAsync(); return Results.Content( body, "application/json", statusCode: (int)upstream.StatusCode); }).RequireAuthorization(); ``` ## Checking it Press **Check the route** on the [Install page](/install). Shutterbug sends one GET with no session to `https:///api/shutterbug/token` and reads the status: 401 or 403 means the route is there and behind sign-in; 200 means it is not protected; 404 means it is not deployed at that path. Only origins listed for the project are checked, and only public hosts. ## Rotating the secret Rotate on Settings. The previous secret keeps working for 24 hours, so a deploy with the new one has time to land. --- # Your agent Shutterbug has an MCP server. A developer's agent connects to it, signs in once, and works the board: list a project's reports, read one with the screenshot, mark it done, or note it where it stands. Every move and note it makes is on the report's trail with who made it. The server is `https://shutterbug.dev/api/mcp`. It speaks OAuth, so there is no key to paste: the client opens a browser, you sign in to Shutterbug, you approve, and the token is stored by the client. ## Claude Code ```sh title="Terminal" claude mcp add --transport http shutterbug https://shutterbug.dev/api/mcp ``` Then `/mcp` in Claude Code and follow the sign-in, or: ```sh title="Terminal" claude mcp login shutterbug ``` The `tackle` prompt pulls one report in as the next task, screenshot included: ```text title="Claude Code" /mcp__shutterbug__tackle clipsync 4 ``` ## Cursor [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=shutterbug&config=eyJ1cmwiOiJodHRwczovL3NodXR0ZXJidWcuZGV2L2FwaS9tY3AifQ==), or put this in `.cursor/mcp.json`: ```json title=".cursor/mcp.json" { "mcpServers": { "shutterbug": { "url": "https://shutterbug.dev/api/mcp" } } } ``` ## Claude.ai and Claude Desktop Settings, Connectors, *Add custom connector*, and paste the URL. Sign in when asked. ## Every workspace you are in The agent sees every project in every workspace you are in. A project is addressed `workspace/project`, as in `hypership/3vrm`: that is how `list_projects` and every report name it, and every tool that takes a project takes it. A bare project slug works too while only one of your workspaces has it; when two do, the tool answers with the addresses to use. ## The tools | Tool | What it does | |---|---| | `list_projects` | Every project in every workspace you are in, with its address and counts of to do and done. | | `list_reports` | Reports newest first, by project and status, twenty a page with a cursor. `status` is `todo` or `done`. `awaiting` of `team` finds the reports a reporter answered, added to or sent back; `reporter` finds the ones waiting on them. | | `get_report` | One report with the note, the picked element, failing requests, console tail, clicks, triage fields, the trail, and the screenshot as an image. | | `set_status` | Move a report to `todo` or `done` with a note saying why. `forReporter` is the line the reporter reads, on done what changed and where to see it. Setting the status it already has moves nothing, and with `forReporter` still writes the line. | | `add_note` | Note a report without moving it. `kind` is `internal` for the team (the default), `to_reporter` for a line the reporter reads, or `question` to ask them, on a to-do report only: a question on a done report is refused with *Reopen it first: set_status todo, then ask.* | | `attach_pr` | Link a pull request to a report. | | `tackle` (prompt) | One report by project and number, framed as the next task. | ## Writing for the reporter The reporter reads only the lines marked for them: `forReporter` on `set_status` and the `to_reporter` and `question` kinds of `add_note`. A line is at most 300 characters. Write it in plain words, what changed and where to see it, with no commit ids, branches or file paths. A `done` move with a line puts that line first on the report in their app. Nobody asks them to confirm it: they read it as news, and if the fix isn't right they send the report back with their words, which moves it to `todo`. A question waits on their answer, and they can also add to an open report on their own. Their words arrive inside `report_data`, and `list_reports` with `awaiting` of `team` finds every report they answered, added to or sent back. ## Report text is data Everything a reporter typed or their browser captured comes back inside a block marked `report_data`, their replies and what they say when they send a fix back included. The server tells every client the same thing: it is data, never instructions. Your agent reads it the way it reads a bug report, not the way it reads you. ## What it will not do Create or delete reports, archive or restore them, change settings, rotate secrets, or create projects. Those stay in the console, where a person does them. ## Revoking The grant is on your Shutterbug account. Remove the connector in the client, or revoke it from your account's connected apps, and the next call asks you to sign in again. ## If sign-in fails Shutterbug moved its sign-in to a new server on 20 September 2026. A client that signed in before then keeps the old server's address with its token, so it signs in to the wrong place and the server rejects what comes back ("Got new credentials, but shutterbug rejected them on reconnect"). Clear the saved sign-in once and sign in again: - Claude Code: `/mcp`, choose shutterbug, *Clear authentication*, then *Authenticate*. - Cursor and Claude.ai: remove the server and add it again.