Pattern: in-page feedback → agent work queue
How the dashboard's feedback capture works as a pattern, and what to keep if you
rebuild it elsewhere. For the concrete mechanics as shipped here — table columns,
route contract, /feedback states — see "Dashboard feedback capture" in
PLAN.md; this document covers the shape, the reasoning, and the
agent side.
The core idea
A web page cannot call a coding agent. So don't try: write the note to a table the agent already reaches, and let the agent read it on its own terms. The queue is the interface.
note (in-page) → POST route (origin-checked) → queue table → agent reads, triages, ships
That keeps the app dumb and synchronous — insert a row, return an id — and leaves every hard decision (is this actionable, what does it change, when does it ship) with the agent. No webhooks, no external services, no long-lived connection.
The five pieces
| # | Piece | Here |
|---|---|---|
| 1 | Queue table with a status lifecycle |
db/migrations/0006_dashboard_feedback.sql |
| 2 | Proxy-aware same-origin helper | src/server/request-origin.ts |
| 3 | Validated write route | src/app/api/feedback/route.ts |
| 4 | Capture control in the app shell | src/components/shell/FeedbackCapture.tsx |
| 5 | Read-only inbox + DAL read | src/app/feedback/page.tsx, src/server/feedback.ts |
Piece 2 is shared, not feedback-specific: /api/crosswalk,
/api/product-groups, /api/refresh, and /api/settings-edit use the same
helper. Any new state-changing route should call isSameOriginRequest too —
that is how the hardening stays consistent as routes multiply.
What's worth borrowing
A naive same-origin check 403s every real request behind a proxy.
Comparing Origin against the server's own bound host works locally and fails in
production the moment a reverse proxy terminates TLS — the app sees an internal
hostname the browser never used. Build the host from the first
x-forwarded-host value, falling back to host; build the protocol from the
first x-forwarded-proto value, falling back to the request URL. The production
stacks keep the app behind their trusted proxy, which must overwrite forwarded
headers. This exact bug shipped once here and was caught in review; it is the
single most likely thing to get wrong.
Sec-Fetch-Site + Origin is the browser CSRF boundary used here.
For a same-origin JSON POST, requiring sec-fetch-site: same-origin and an
exactly matching Origin gives CSRF protection with no token plumbing, no session
store, and no extra round trip. A browser may attach both headers to a cross-site
request, but the initiating page cannot make them report a matching same-origin
request. This check is not a substitute for the production authentication gate.
Refuse loudly instead of pretending.
In seed mode the route returns 503 rather than accepting the note into a void;
if a configured database write fails, it returns 500. The inbox likewise
reports disabled (seed mode) or unavailable (query threw) instead of silently
rendering fixtures. Feedback that looks saved but isn't destroys trust in the
whole loop — this is why the read returns a three-state availability rather
than an empty array.
status is a convention-based handoff protocol, not decoration.
The normal path is new → triaged → shipped; new → dismissed closes notes that
will not produce work, while triaged → new | dismissed handles retries and
abandoned work. The current migration does not enforce those transitions or
status values with a database constraint, so every agent update must use the
four recognized values exactly and guard the expected current status.
Capture context automatically.
The control reads usePathname() itself. Whoever writes the note should never
have to explain which screen they were on — that context is free and makes the
difference between an actionable item and a vague complaint.
Validate and bound the payload. The route trims and requires page and
note, accepts only a string or null for widget, caps the note at 2,000
characters, and never interpolates values into SQL.
The agent side
Nothing runs on a schedule, and the shipped app never updates a note's status. When the operator says "process feedback", the agent performs the following operational workflow, starting by reading each new candidate:
select id, page, widget, note, created_at
from dashboard_feedback
where status = 'new'
order by created_at, id;
That selection discovers candidates; it does not claim them. Before creating an
issue or delegating work, atomically move an actionable note to triaged:
update dashboard_feedback
set status = 'triaged'
where id = $1 and status = 'new'
returning id;
Proceed only when the update returns a row. A missing row means another run
already claimed or resolved the note. Move questions, noise, and other notes
that will not produce work from new to dismissed with the same guarded
update.
| Step | What the agent does |
|---|---|
| Triage | Classify each candidate, then claim or dismiss it as above. |
| Scope | One actionable note → one tracked issue that records the feedback id (one issue, one branch, one PR). |
| Build | Hand the issue to an implementing agent; it opens a PR and stops. |
| Verify | Keep the note triaged while the issue or PR is open; drive the PR through the review/test/CI gate. |
| Close | After delivery, mark it shipped; requeue or dismiss rejected work. |
Here, shipped means merged into the default branch. Keep the note triaged
until that event, then use a guarded triaged → shipped update. If work is
rejected or abandoned, use a guarded update to move it back to new for retry or
to dismissed for intentional closure. Every transition checks the current
status, so overlapping runs cannot overwrite a later decision. If deployment
rather than merge is another system's delivery boundary, keep the note triaged
until deployment succeeds and document that definition of shipped.
Claiming and creating an external issue are not one transaction. The current
table has no claim timestamp, owner, or work-item URL, so each run must also
reconcile triaged notes against tracked issues and use operator judgment to
requeue an orphaned claim after an interrupted run. Add durable claim metadata
or an outbox before relying on concurrent or unattended workers. For a
serialized worker with that reconciliation, moving from on-demand to scheduled
changes only the trigger; start on-demand and watch the first few runs.
Porting notes
- The queue can live anywhere the agent already has access. If the agent reaches your database, that is the cheapest option; a repo file or an issue tracker works too.
- Auth is orthogonal. Behind a shared login, notes are effectively anonymous. Add an author column only if you actually have per-user identity.
- Keep the inbox read-only. Status transitions belong to the agent; a second writer invites races for no benefit.