Sprout: A Next.js + Supabase Template Where Feedback Writes Its Own Git Branch
A template that wires a public feedback form straight into GitHub — every submission becomes a git branch via Claude Code, with no human touching the pipeline in between.
Sprout is a Next.js + Supabase template built around one idea: a feedback form shouldn't just save a row in a database — it should trigger something. Every insert fans out through a Database Webhook, a GitHub repository_dispatch event, and a scoped Claude Code run that turns the raw message into a git branch, ready for a human to pick up. This post walks through the exact wiring, what it actually does today versus what it's designed to grow into, and the guardrails that keep an unsupervised AI agent safe to run on every submission.
The idea #
Most feedback forms end the same way: a row lands in a table, and then it's up to someone to remember to look. I wanted to see how far I could push the other direction — what if the row landed in the table and immediately became something a developer could act on, with zero manual triage in between?
The shape I landed on: treat feedback the same way you'd treat any other event that should trigger automation — a webhook, not an inbox. Supabase already fires a Database Webhook on INSERT. GitHub Actions already run off arbitrary dispatch events. The missing piece was the middle step — something that reads a feedback message and turns it into developer-shaped output — and that's exactly the kind of narrow, well-specified task an AI coding agent is good at, as long as it's given a tightly scoped job and nothing more.
That's what Sprout wires together, end to end, as a template anyone can clone and stand up against their own Supabase project and repo.
Architecture #
form -> POST /api/feedback -> Supabase (feedbacks table)
|
Supabase Database Webhook (INSERT)
v
POST /api/webhooks/feedback (this app)
|
GitHub repository_dispatch event
v
.github/workflows/feedback-branch.yml -> Claude Code
|
git branch: feedback/<id>-<slug>
Six hops, three systems (the Next.js app, Supabase, GitHub), and — this is the part I think is actually interesting — no code in this repo ever calls Claude directly. The workflow fires on a GitHub event like any other CI job would; Claude Code just happens to be the thing running inside it.
| Stage | What happens |
|---|---|
| Form | A minimal page (src/app/page.tsx) with a message + optional email field, posting to /api/feedback. |
| Insert | /api/feedback uses Supabase's service-role client to insert into public.feedbacks. The anon client is wired up but unused today — reserved for a future public-facing view. |
| Database Webhook | Configured in the Supabase dashboard (not in code) to fire on every INSERT into feedbacks, POSTing the new row to /api/webhooks/feedback. |
| Webhook relay | /api/webhooks/feedback checks a shared-secret Authorization header, then fires a repository_dispatch event (feedback-received) against the GitHub repo via the API. |
| GitHub Action | .github/workflows/feedback-branch.yml catches that dispatch and runs anthropics/claude-code-action with the feedback's id, email, created_at, and message baked directly into the prompt. |
| Claude Code | Reads CLAUDE.md for the exact procedure, creates a branch, writes a file, commits, pushes. |
What Claude actually does today #
I want to be precise about this, because "feedback automatically becomes a fix" is a much bigger claim than what's actually wired up right now. Sprout, as it stands, automates the triage step — not the fix.
When the workflow fires, Claude Code is handed a single, self-contained prompt (there's no issue or PR comment to read — nobody's watching this run, so the prompt has to carry everything) and a narrow set of instructions from CLAUDE.md:
- Slugify the first ~6 words of the message into a short, lowercase, hyphenated label.
- Create a branch off the default branch:
feedback/<id>-<slug>. - Add
feedback/<id>.mdwith the submission's timestamp, sender, and the message verbatim. - Commit as
feedback: <slug>and push. - Stop. No pull request, no other files touched, no amending or force-pushing existing branches.
That's the whole job. The output isn't a fix or a feature — it's a feedback item that already exists as a branch in your repo, with a real commit and a real file, instead of a row you have to remember to go read. It's the difference between a feedback inbox and a feedback queue — the second one is much harder to silently ignore.
Why the guardrails matter more than the automation #
The interesting engineering here isn't "call an AI on a webhook" — that part's easy. It's making an unsupervised run, triggered by an anonymous member of the public typing into a <textarea>, safe to execute against a real repository with zero human review before it happens. A few choices do most of that work:
- A scoped tool allowlist. The workflow invokes Claude with
--allowedTools "Read,Write,Bash(git *)"— nothing else is reachable, so even a maximally adversarial feedback message has no path to, say, running arbitrary shell commands or reaching outside the repo. contents: write, nothing more. The GitHub Action's permissions block grants exactly the scope the task needs and nothing that would let it touch settings, other branches, or repo configuration.- A prompt that's also a spec.
CLAUDE.md's "Automated feedback triage" section isn't documentation Claude might read — it's the literal procedure the direct prompt tells Claude to follow "exactly." Anything a submitted message tries to inject can ask Claude to do something outside that procedure, but the instructions are explicit that the job is fixed regardless of what the message says. - Idempotent by design. Branch names are
feedback/<id>-<slug>— deterministic from the row's UUID. If Supabase redelivers the same webhook (it does, on retries), Claude is instructed to check for an existing branch and stop rather than overwrite it. - RLS on, no policies. The
feedbackstable has row-level security enabled with nothing granted — every read and write goes through the server's service-role client. The browser never talks to Supabase directly. - A shared secret on the webhook.
/api/webhooks/feedbackrejects anything that doesn't carry the exactAuthorization: Bearer <FEEDBACK_WEBHOOK_SECRET>header configured on the Supabase side, so the dispatch endpoint isn't just an open trigger for anyone who finds the URL.
None of these are exotic. They're the same instincts you'd apply to any endpoint a stranger can hit — the only difference is the thing behind the endpoint happens to be an AI agent instead of a form handler.
Where it's meant to grow #
The feedbacks table already has a status column — new, triaged, done — but nothing transitions it automatically yet; every row sits at new until someone changes it by hand. The README is explicit that this is deliberate scaffolding, not an oversight, and lists the next two steps as template extensions rather than finished features:
- Open a draft PR instead of a bare branch — add that step to
CLAUDE.md's triage section and grant the workflowpull-requests: write. This is the natural next rung: Claude already has the branch and the context; a draft PR is a small step from there, but a meaningfully bigger claim about what it's allowed to propose. - Close the loop on status — have the workflow call back into
/api/feedback/:idonce a branch exists, flippingnew→triagedso the dashboard (once there is one) reflects reality instead of everything sitting atnewforever.
The bigger reach — Claude reading the feedback, understanding it well enough to actually implement a fix or a small feature, and opening a PR with real code changes — is exactly what the architecture is shaped to support, but it isn't what's running today. Getting there responsibly means widening the tool allowlist and the prompt's scope one deliberate step at a time, with the same "exactly this, nothing else" discipline the triage step already uses — not skipping straight to full write access because the plumbing happens to support it.
Setting it up #
Sprout is a template, meant to be cloned and pointed at your own Supabase project and repo:
- Create a Supabase project and run
supabase/migrations/0001_create_feedbacks.sql. - Copy
.env.exampleto.env.localand fill in the Supabase URL/keys, aFEEDBACK_WEBHOOK_SECRETof your choosing, and aGITHUB_TOKENscoped tocontents: writeon your repo. - In the Supabase dashboard, add a Database Webhook on
feedbacks→INSERTpointing athttps://<your-deployment>/api/webhooks/feedback, with the same bearer secret as a header. - In the GitHub repo, add an
ANTHROPIC_API_KEYsecret sofeedback-branch.ymlcan run Claude Code.
From there, every submission through the form produces a branch within seconds of the insert — no polling, no cron job, no dashboard to check.
Key takeaways #
- Treating a feedback table like an event source — not a place rows go to be forgotten — turns "someone should look at this eventually" into "this already exists as a branch."
- The valuable part of the design is the guardrails around the AI agent, not the AI agent itself: a scoped tool allowlist, minimal permissions, an idempotent branch name, and a prompt that behaves like a fixed procedure rather than an open-ended instruction.
- It's honest to call this a triage automation today, not a fix automation — the gap between "here's a branch with the feedback recorded" and "here's a branch with the feedback resolved" is exactly where the interesting, harder work still is.
- Building the extension points (draft PRs, status transitions) into the schema and docs from day one, without wiring them up yet, keeps the template honest about what it does versus what it's aimed at.
Conclusion #
Sprout isn't trying to prove that an AI agent can write your fixes for you — not yet. It's a proof that the wiring for that future can be built safely today: a public form, a database, a webhook, and a scoped agent run that never needs a human to kick it off. The triage step it automates now is small on purpose. The architecture is what's built to grow.
Related content
Webhooks retry, so make the receiver idempotent — not the sender reliable
TIL that webhook delivery is at-least-once, not exactly-once. Instead of trying to prevent duplicate deliveries, derive a deterministic ID from the event and let the receiver refuse to redo work it already did.
A global color-scheme: dark leaks into print/PDF output unless you override it
TIL that setting color-scheme: dark once on html/body for a dark-themed site makes the browser paint its default canvas background dark everywhere that property cascades — including print and PDF output — unless @media print resets it.
A JSX expression-container attribute silently loses its value in next-mdx-remote
TIL that generating attr={"..."} into MDX source instead of attr="..." made a prop come back as an empty object on every render, with no error anywhere in the pipeline — found while writing the Obsidian-callout preprocessor for this site.