Papercuts is one place where you and your agents report friction. Each papercut becomes a GitHub issue in the repo of the project, or a row in a gitignored SQLite file of the repo (the local store).
Small problems get lost. A button overlaps on the phone. A tool prints output that an agent cannot use. A rule in AGENTS.md points to a file that moved. Each one costs a few minutes, again and again.
Papercuts gives people and agents the same short path to report them. All reports land in the same place, in the same format. The agent that picks up the work reads them all the same way. The result is a steady flow of small fixes.
Papercuts is for local sites: dev and review copies on your own machine or home lab. It is not a feedback widget for public visitors.
- Widget. A tab on the edge of a page. People only give feedback: one input, by voice or by text, then Send. Agents triage it, so people choose no kind and fill no fields. Everything else comes along in the background: the element that you touched last, the URL, the place, the viewport, the selected text and the console errors. "Mark" adds more: numbered elements, boxes around regions, and drawings on a picture or a screen picture. The panel also lists the open papercuts of the repo with their replies, and the ones fixed in the last 14 days with the fix note.
- JS API. An agent that drives a browser calls
papercuts.report()on the same page. It does not need to click the tab. - CLI. An agent in a terminal runs
papercuts add tool "...". The output follows the AXI rules for agent tools. - Relay. A small server that holds the GitHub credential, serves the widget, and creates the issues. The browser never sees a token.
Papercuts has no runtime dependencies. It needs Node.js 22.13 or later (the local store uses the built-in node:sqlite).
This checklist is for the agent or person who adds papercuts to an app. Do the steps in this order:
- In the repo of the app, run
papercuts init, orpapercuts init --store localfor the local store (see Where papercuts go). It detects the stack, writes.github/papercuts.jsonand the papercuts block inAGENTS.md, and prints the exact steps for your stack. - Add the script tag on the server, only when
PAPERCUTS_URLis set (see Load papercuts only where a relay exists). - Add a test: without
PAPERCUTS_URLthe page has nopapercuts, with it the page has exactly one tag. - Set
PAPERCUTS_URLonly for the copies with a relay, for example the lab and dev copies. - Edit
.github/papercuts.json: the kinds, the fields, the quick phrases and the labels of this app (see The ticket config). - Run
papercuts config --file .github/papercuts.json --checkbefore the push, andpapercuts config --checkafter it. Both exit 1 on a warning.
First, run a relay (see Run the relay). Then add one tag to the <head> of the page. Put it early, so that the widget sees console errors from the start:
<script src="https://papercuts.lab.example.com/papercuts.js" data-repo="owner/name"></script>Load the page. The tab "Papercut" shows at the right edge. The keyboard shortcut is Alt+Shift+P. Ctrl+Enter sends. You can drag the tab up or down, and the page keeps the position.
If the relay does not answer, the page shows no tab.
Optional attributes on the script tag:
| Attribute | Use |
|---|---|
data-repo |
Required. The repo for the issues, as owner/name. |
data-lang |
The language of the widget: en (default), de, or a language that the site adds (see below). The JS API answers in English. |
data-speech-lang |
The language for speech input, for example de-DE. Default: the lang of the page. |
data-commit, data-branch |
The version of the site. The issue shows them. |
data-storage |
A comma list of localStorage keys to include, or * for all keys (at most 50). By default the widget sends no localStorage, because it can hold tokens or private data. |
data-storage-skip |
With data-storage="*": a comma list of keys to leave out, for example a room code. |
data-mode |
download: see Without a relay. |
data-accent |
The accent color of the widget, any CSS color. Default: a coral red. |
data-shortcut |
The key combination that opens the panel. Default alt+shift+p. off turns it off. |
data-gesture |
off turns off the two-finger long press on touch screens. |
data-voice |
off turns off voice first on touch screens. |
Hooks for the page:
window.papercutsContext = (el) => ({ openCard: "c12" })adds your own fields to each report.elis the first marked element, ornull.window.papercutsHidden = () => truehides the tab, for example in a print view or a kiosk mode. The widget asks once per second.window.papercutsReplay = () => [{ click: '.card[data-id="k12"]' }]records up to five clicks that bring the page back to the reported state, for example a dialog without a URL.papercuts replayruns them.window.papercutsStrings = { fr: { send: "Envoyer", close: "Fermer" } }adds or changes the words of a language. A missing word falls back to English. Set it before the page finishes loading. The keys are in the tableTat the top ofwidget/papercuts.js.
In a modal <dialog> or in fullscreen, the widget moves into the dialog or the fullscreen element, so that it stays usable.
The element that you touched last, in the 15 seconds before you open the panel, is attached by itself. "Mark" opens the live page with a toolbar at the bottom:
- Tap marks an element. A second tap removes the mark. Larger and Smaller move the newest mark to its parent and back.
- Box marks a region: drag a box around it.
- Draw opens a picture of the page in the drawing sheet: pen and numbered pins.
Each mark gets a number on the page and a row in the panel, with an optional note. The issue lists the marks, and papercuts replay frames each one with its number.
Do not put the tag into a published copy of a site. A published copy has no relay, and the tag shows the relay URL to every visitor. Add the tag on the server, and only when an environment variable is set:
# FastAPI: build index.html yourself when PAPERCUTS_URL is set.
# Do not use FileResponse for it: its 304 keeps a cached copy without the tag.
html = INDEX.read_text()
if url := os.environ.get("PAPERCUTS_URL"):
html = html.replace("</head>", f'<script src="{url}/papercuts.js" data-repo="owner/name"></script></head>', 1)
return HTMLResponse(html, headers={"Cache-Control": "no-cache"})# nginx: a template under /etc/nginx/templates/ (the official image fills in ${PAPERCUTS_URL})
sub_filter '</head>' '<script src="${PAPERCUTS_URL}/papercuts.js" data-repo="owner/name"></script></head>';
sub_filter_once on;For nginx, use two config files and let the entrypoint choose one, so that no tag goes out when the variable is empty. For a static build (Vite), add the tag when the server delivers the page, not at build time, so that one build works with and without papercuts.
- Keys.
Alt+Shift+Popens and closes the panel. Change it withdata-shortcut, for exampledata-shortcut="ctrl+k". - A link. A URL with
#papercut(or?papercut=open) opens the panel when the page loads. Use it for a bookmark, a QR code or a home-screen icon. The widget removes it from the URL. - Two fingers. On a touch screen, hold two fingers on the page for 0.6 seconds.
- Quick phrases. The
presetsof the ticket config show as chips. One tap fills the kind and the sentence. - Your own button.
papercuts.open({ text: "...", kind: "idea", marks: ["#cart", element], fields: { ... } })opens the panel with a draft, for a menu entry of the app. Every key is optional.markstakes elements or CSS selectors.
On a touch screen with speech input, a large "Tap to speak" button leads the panel. The words show while you speak, and you can edit them. On desktop, the microphone is a small button in the text field. Speech uses data-speech-lang, else the lang of the page. Without speech input in the browser, the panel leads with the text field.
With ?papercuts=download in the URL, or data-mode="download", the widget shows the tab without a relay. Send becomes "Download report" and saves the report as a JSON file. File it later with papercuts import <file>. For this mode, serve a copy of widget/papercuts.js from the site itself.
.github/papercuts.json in the repo of the app defines what a papercut of this app contains. The relay reads it from the default branch (cached for 5 minutes) and applies it to reports from the widget, the JS API and the CLI. Every key is optional. Without the file, the defaults apply.
{
"$schema": "https://raw.githubusercontent.com/Tim-Conrad/papercuts/main/schema/papercuts.schema.json",
"kinds": ["bug", "ux", "copy", "idea"],
"fields": [
{ "id": "expected", "label": "What did you expect?", "required": true },
{ "id": "device", "label": "Device", "options": ["phone", "tablet", "desktop"] }
],
"presets": [{ "kind": "ux", "text": "Too small to tap" }],
"labels": ["area:web"],
"labelsByKind": { "bug": ["bug"] },
"assignees": ["your-login"],
"context": { "storage": ["theme"], "skipStorage": ["room-code"] },
"lang": "en"
}| Key | Use |
|---|---|
kinds |
The kinds that agents use in this repo. Use the built-in kinds only, so that labels stay the same in all repos. |
fields |
At most 8 extra fields for agents. A field with options is a choice, else one line of text. A required field is required for agent reports. A field with kinds belongs to those kinds only, for example { "id": "severity", "label": "Severity", "options": ["low", "high"], "kinds": ["bug"] }. People never see the fields. open({ fields }) can fill them for a person. |
presets |
At most 12 quick phrases for the widget. One tap fills the sentence and, silently, the kind. |
labels, labelsByKind, assignees |
Added to each new issue. |
context |
The localStorage keys to include, the same as data-storage and data-storage-skip. |
lang |
The language of the widget when the page sets no data-lang. |
store |
github (default) or local. See Where papercuts go. |
A broken part never breaks the page or a report. The relay drops it, uses the default, and lists a warning. papercuts config shows the warnings. The schema in schema/papercuts.schema.json lets an editor check the file. Page attributes win over the file.
Agents fill the fields with papercuts add <kind> "<text>" --field expected="...", and pages with papercuts.report({ ..., fields: { expected: "..." } }). An unknown field or a missing required field gives an error with the list of valid fields.
In a terminal:
npx github:Tim-Conrad/papercuts add tool "gh-axi pr view drops the PR body" --where "gh-axi pr view"
npx github:Tim-Conrad/papercuts # the open papercuts of this repo
npx github:Tim-Conrad/papercuts view 12
npx github:Tim-Conrad/papercuts shot 12 # save the picture of #12 as a PNG
npx github:Tim-Conrad/papercuts replay 12 # show the page as the reporter saw it
npx github:Tim-Conrad/papercuts comment 12 "Option A: ... Option B: ..."
npx github:Tim-Conrad/papercuts done 12 --fix "gh-axi prints the body since commit abc1234"
npx github:Tim-Conrad/papercuts config # the ticket config of this repo
npx github:Tim-Conrad/papercuts init # set up papercuts in the repo of an appThe CLI uses the gh login of the host, or the token file of papercuts setup. It finds the repo from the git remote origin, or from --repo owner/name.
replay opens the URL of the report with the same viewport, hash, scroll position and localStorage keys, frames the element in red, and writes a PNG. It runs Playwright through uv. Without uv, it prints the replay data, so that an agent can drive its own browser. Use --base <url> to open the same page on another host, for example a dev copy without login.
In a browser, on a page with the widget:
if (window.papercuts) {
await papercuts.report({ kind: "ux", text: "The save button is under the keyboard at 412 px", where: "/settings" });
}report() takes kind, text, where, fields, marks and from ("agent" by default, "human" for a report that a person wrote in the app's own form). report() never throws. It returns { ok, issue, url, deduped }, or { ok: false, error, help, queued }. A report that the relay cannot take now waits in the browser (IndexedDB) and goes out later. The browser removes known secret patterns before it stores a report.
The repo includes a skill for agents (skills/papercuts/SKILL.md). The file AGENTS.md holds a block to copy into the AGENTS.md of a project.
Run papercuts setup once per machine. It writes ~/.config/papercuts/config.json and installs a session-start hook for Claude Code and Codex. The hook shows the open papercuts of the current repo at the start of each agent session, and nothing when there are none. The hook calls papercuts from the PATH first, so that a Node.js upgrade does not break it.
Each repo chooses its store when you integrate papercuts:
| Store | Command | Where papercuts go |
|---|---|---|
| GitHub (default) | papercuts init |
GitHub issues, see the targets below |
| Local | papercuts init --store local |
.papercuts/papercuts.db, a SQLite file in the main checkout. init adds .papercuts/ to .gitignore. |
The local store needs no GitHub account, no credential and no network. Use it for a project without a GitHub remote, for private notes that must not become issues, or offline. init --store local writes "store": "local" into .github/papercuts.json. All commands then read and write the local file: add, the home view, the session hook, list, view, comment, done, shot and doctor. A git worktree uses the file of its main checkout. Pictures go to .papercuts/shots/. PAPERCUTS_STORE=github|local overrides the setting for one command. A --repo for another repo always goes to GitHub.
For the widget, start a local relay in the repo folder: PORT=8787 papercuts serve. With the local store, the relay accepts the origins http://localhost:* and http://127.0.0.1:* and any repo name by default. Set PAPERCUTS_URL=http://localhost:8787 for the dev server.
For the GitHub store, papercuts setup chooses the target:
| Target | Command | Where issues go |
|---|---|---|
| Per project | papercuts setup --target per-project |
The repo of the page or of the git remote |
| Central | papercuts setup --target central --repo owner/papercuts-inbox |
One inbox repo. The label project:owner/name keeps the source. |
| Credential | Command | Scope | Pictures |
|---|---|---|---|
gh login (default) |
papercuts setup --target per-project |
All scopes of your gh login |
Upload into the issue |
| Fine-grained token | papercuts setup --target per-project --credential file --token-file <path> |
"Issues: read and write" and "Contents: read" (for the ticket config), only the repos you choose | GitHub refuses them. The relay keeps them and the issue links to them. |
The gh login works at once. Its token usually has the scopes repo and workflow, which is much more than papercuts needs. If an attacker takes over the relay, the attacker can do everything that the token allows. Use a fine-grained token if that risk is too high for you.
Run papercuts doctor to check the credential and the access.
The relay reads its settings from the environment:
| Variable | Default | Use |
|---|---|---|
PORT |
8000 |
The port of the relay. |
PAPERCUTS_STORE |
the store of .github/papercuts.json in the folder, else github |
github or local. |
PAPERCUTS_HOST |
0.0.0.0, local store: 127.0.0.1 |
The address that the relay listens on. |
PAPERCUTS_DB |
.papercuts/papercuts.db of the main checkout |
The SQLite file of the local store. |
PAPERCUTS_ORIGINS |
https://*.lab.t7d.dev, local store: http://localhost:*,http://127.0.0.1:* |
Sites that can send reports and read the list. * matches one host label or one port. |
PAPERCUTS_REPOS |
Tim-Conrad/*, local store: */* |
Repos that can receive issues. |
PAPERCUTS_TARGET |
per-project |
per-project or central. |
PAPERCUTS_CENTRAL_REPO |
none | The inbox repo for the central target. |
PAPERCUTS_CREDENTIAL |
gh, or file if a token file is set |
gh or file. |
PAPERCUTS_TOKEN_FILE |
none | A file with a fine-grained token. |
PAPERCUTS_GH_MOUNT |
none | A read-only folder with the gh login (config.yml, hosts.yml). The relay gives gh a private copy in its temp folder before each call. |
PAPERCUTS_PUBLIC_URL |
none | The public URL of the relay, for the links to pictures that GitHub refused. |
PAPERCUTS_SHOTS_DIR |
shots |
The folder for those pictures. |
PAPERCUTS_LIST_CACHE_MS |
30000 |
How long the relay keeps the list of open papercuts per repo. |
PAPERCUTS_CONFIG_CACHE_MS |
300000 |
How long the relay keeps the ticket config per repo. |
On a laptop, for sites on localhost:
PAPERCUTS_ORIGINS="http://localhost:*" PAPERCUTS_REPOS="owner/*" npx github:Tim-Conrad/papercuts serveThen use http://localhost:8000/papercuts.js in the script tag.
In a home lab, run the container behind your single sign-on proxy. docker-compose.yml shows the setup of the author with Traefik and Authelia. It mounts ~/.config/gh read-only and runs the container as the user that owns it, because gh keeps hosts.yml with mode 600. Change the host names, the origins, the repos and the user ID for your lab.
The widget sends only simple requests: POST with Content-Type: text/plain and no custom headers. Browsers then send no CORS preflight. A login proxy can therefore stay in front of every route of the relay. The session cookie of the proxy must cover the domain of the site and of the relay. If the login expired, the widget keeps the report and asks you to log in again.
Routes:
| Route | Use |
|---|---|
GET /papercuts.js |
The widget. |
POST /report |
File one report (max 12 MB with a picture). |
GET /list?repo=owner/name |
The open papercuts and the ones closed in the last 14 days, with replies, fix note and place. A browser must come from an allowed origin. |
GET /config?repo=owner/name |
The checked ticket config of the repo, with warnings. A browser must come from an allowed origin. |
GET /shots/<id>.png |
A picture that GitHub refused. |
GET /status, GET /healthz |
Health. |
papercuts shot reads relay pictures from PAPERCUTS_RELAY_INTERNAL if you set it, for example the address of the container on the Docker network. Otherwise it uses the public link.
- Title:
papercut:and the first 70 characters of the sentence. - Labels:
papercut,from:humanorfrom:agent, andkind:<kind>. The central target addsproject:<owner/name>. - Kinds:
bug,ux,copy,tool,rule,path,idea,other. People choose no kind: a report from the widget isother, unless a preset oropen()sets one. Agents choose the kind. - Body: the sentence, the picture and the numbered pins if there are any, a YAML block with the context, and HTML comments:
papercut-id(one per report),papercut-place(where the reporter was), and for agentspapercut-fp(the fingerprint).
Repeats:
- A report with an ID that exists already returns the first issue. A retry therefore never makes a second issue. This also holds in the first seconds, before GitHub lists a new issue.
- A report from an agent with the same kind, place and sentence as an open papercut adds a
+1comment to that issue. Digits, case and spaces do not change the fingerprint. - A report from a person always opens a new issue.
- The token stays on the relay or in the
ghlogin of the host. It never goes to the browser, into an issue, into a log, or into a command line.papercuts shotsends it only togithub.com. - Papercuts replaces known secret patterns with
[redacted]before a report leaves the browser or the machine. Examples are GitHub tokens,Bearervalues, AWS keys, andpassword=.... The relay does it again. This catches common mistakes. It does not find every secret. - The relay accepts reports only from the origins in
PAPERCUTS_ORIGINS, and only for the repos inPAPERCUTS_REPOS. A report without anOriginheader gets a 403. A browser from another origin cannot read the list. - The relay has no login of its own. Run it behind a login proxy, and do not expose it on a network that you do not trust.
- The widget sends no
localStorageunless the site names the keys. - Do not load papercuts on a page that shows decrypted or secret content. A report copies the text of the picked element and can include a picture of the page.
- A token file on the host is still readable by any program of the same user. A later version adds a credential source that asks a separate broker process to do the call, so that the token never leaves it.
The CLI follows the ten AXI principles:
- Output is TOON on stdout.
papercutswith no arguments shows the open papercuts.- Lists carry counts, and empty results say what is empty.
- Long bodies are cut, with the total size and a
--fullhint. - Each error has a
help:line with the command that fixes it. - Exit codes:
0success (also a no-op),1error,2usage error. - An unknown flag fails at once, before any network call.
- Each command has
--helpwith examples.--versionprints the version.
npm test # unit tests (node:test)
npm run check:browser # browser check of the widget (Playwright through uv)
node bin/papercuts.js skill --checkMIT. See LICENSE.
direnv allow(ornix develop) loads the tools fromflake.nix. Python packages go into.venvfrom the repo's own pins.- Tests:
npm test. wt upstarts the preview of this checkout or worktree:https://<branch>--papercuts.review.t7d.dev.https://papercuts.review.t7d.devshows the current worktree (wt switch <branch>), andhttps://review.t7d.devlists all previews.- The relay of this worktree with a local store in .preview/ (no GitHub issues). Status page: /status.
wt new <branch>makes a worktree,wt lslists them,wt rm <branch>removes one. The preview is defined incompose.preview.yml.