Embeddable, no-install, framework-agnostic end-user feedback — a drop-in widget
that lets any visitor of your deployed web app report a bug or request a feature,
with useful context gathered automatically, routed to a pluggable backend (GitHub
Issues first). Works standalone; integrates cleanly with
enlace as an optional add-on, and never needs an
enlace platform to run. It only reuses enlace's stdlib-only rate-limit helpers, though
installing heed installs the enlace package with its own dependencies (uvicorn, cw).
Status: design phase. The competitive landscape, the design rationale, and the roadmap live in
misc/docs/. Work is tracked in GitHub issues; design decisions in GitHub discussions.
Developer-side tools (browser extensions, Chrome DevTools MCP, Jam, …) only help
you. They can never serve an ordinary end user reporting a problem on your live
site, or asking for a feature. heed is the missing piece: a public, zero-install
capture-and-report widget plus a small self-hostable backend that you own and that
routes wherever you want.
- Widget — one
<script>tag injects a Shadow-DOM-isolated custom element; drops into React, plain HTML, or server-rendered pages alike. Captures a screenshot, page URL, and environment by default; console/network capture is opt-in (privacy). - Backend — a mountable FastAPI router (run on its own, or mounted into
enlace) that validates, stores attachments (viadol), and dispatches to a pluggable sink. - Sinks (strategy pattern) — GitHub Issues / Discussions first; email, Slack, a built-in board, or a database later.
- Agent hand-off — an optional MCP server (
py2mcp) so Claude Code can pull the feedback queue and act on reports.
- Standalone first. heed never needs an enlace platform: the router mounts in any
FastAPI app. The one thing it takes from the
enlacepackage isenlace.ratelimit(trusted-proxy client address + limiters, standard library only), so that security mechanism has one implementation and one test suite; mounting into enlace stays an optional[enlace]extra. - Privacy by default. Heavy capture (console, network, session replay) is opt-in; PII masking is designed in, not bolted on.
- Public endpoint = hostile input. Rate limiting, origin checks, payload caps, and bot protection are first-class — the ingest endpoint is unauthenticated by nature.
- Progressive disclosure. One script tag works out of the box; everything is parametrizable via keyword-only options with smart defaults.
pip install heed # core (backend + models)
pip install "heed[github]" # + GitHub sink
pip install "heed[enlace,mcp]" # + enlace mount + MCP feedback queue
pip install "heed[redis]" # + rate limits shared across workersThe POST /report endpoint is public, so it ships hardened: origin allow-list with CORS, streamed body/field/screenshot caps, strict input validation, per-IP and per-origin rate limits, a honeypot field, and an optional Turnstile/hCaptcha gate. Configure it in code or from HEED_* environment variables:
from heed import HeedConfig, StoreSink, make_app
config = HeedConfig.from_env() # e.g. HEED_ALLOWED_ORIGINS=https://myapp.example
app = make_app(StoreSink({}), config=config)For production, set allowed_origins, trusted_proxies (behind a reverse proxy), rate_limit_backend="redis" with redis_url (several workers), and bot_secret to turn the bot gate on. Embedders also need a Content-Security-Policy that allows the widget: see misc/docs/embedding-security.md.
The widget's category picker sets Report.category; the sink decides what each category becomes:
from heed import CategorySink, CategoryTreatment, GitHubIssuesSink, github_sink_from_token
issues = github_sink_from_token("myorg/myapp", token) # bug -> labelled issue with full context;
# feature/question -> light issue (no env dump)
ideas = github_sink_from_token("myorg/ideas", token, public_label="board")
sink = CategorySink({"feature": ideas}, default=issues) # or route a category to another sinkGitHubIssuesSink(treatments={Category.feature: CategoryTreatment(("idea",), title_prefix="[idea] ")}) changes labels, title prefix and detail ("full" | "light") per category. For a future public board, sinks that can list their items implement public_items(); the GitHub sink returns issues a maintainer labelled public as PublicItems (title, category, status from labels or issue state, link, 👍 votes, dates, never the issue body or the reporter; the item's link opens the issue itself, so keep the tracker public-safe), and for feature and question reports the server also drops the screenshot, console, network and browser details (HeedConfig.light_categories). heed.roadmap(items) / heed.changelog(items) are the two views over them.
The backend serves the widget too, so a site adds one tag:
<script async src="https://feedback.example/heed/widget/heed.js"></script>Options (data-* or heed.init({...})), the heed API, the React wrapper, screenshots and their masking, pinning a build with SRI, events, theming and CSP notes: widget/README.md.
Publish a video, audio takes or images and get a link; whoever has it watches, reads the comments and adds their own (a name per comment, optionally pinned to a moment in the media), without an account:
heed review publish cut1.mp4 --title "Cut 1" --note-file notes.md # prints the link
heed review publish cut2.mp4 --to <page-id> # a new cut, same link and thread
heed review comments <page-id> --json # read the feedback back
heed review publish cut2.mp4 --to <page-id> --password-file pw.txt --expires 7d # gate it (pip install 'heed[gate]')
heed review revoke <page-id> # end the link for everyoneA link sent to a client can carry a password, an expiry and a revoke (heed.review_access, on caplinks; the gate extra): the page answers 401 until unlocked with the password (a cookie; wrong tries limited per client and per page), 410 once expired, 404 once revoked, and pages without any of it stay open as before. The link is a capability (128 random bits in the URL fragment); the thread endpoint is rate-limited, capped and honeypotted. Serve it with make_review_app(rootdir, is_moderator=email_moderator([...])), or heed review serve. On an origin shared with other apps, a login is ambient authority (any script there acts as the owner), so require a typed-in secret instead: is_moderator=token_moderator(secret, also=email_moderator([...])) (heed review serve --moderator-token-env VAR); the review page's Moderate button asks for it and keeps it in memory only. A dedicated origin (e.g. review.<domain>) is the stronger fix and is a deployment decision. The reusable part is heed.threads (a hardened comment thread per key, make_thread_router); heed.review is one consumer of it. Agents: the shipped skill heed/data/skills/heed-review. Rationale: misc/docs/adr/0001-review-pages-and-comment-threads-live-in-heed.md.
misc/docs/research-report.md— the competitive landscape: what to reuse / steal / avoid, design patterns, and gotchas (deep research with adversarial verification).misc/docs/design.md— architecture, the data model, the sink interface, and the spectrum of approaches with pros/cons.misc/docs/embedding-security.md— hardening knobs for operators and CSP guidance for sites that embed the widget.
Author: Thor Whalen. License: Apache-2.0.