This is a submission for the Sanity Challenge, Path Two: Vibe-Code Something Strange
๐ชฆ TL;DR for judges
- Write-up (quality and honesty): My Build Process has the prompts that worked, the one that didn't, every place it got stuck, and what's not live. The full Claude Code session is embedded below.
- Working app: the live site needs no login. Open a zombie's past lives, check Most Haunted, and report a dead bug yourself: an AI coroner drafts its epitaph within seconds.
- Schema: a zombie is a whole new
bugthat points to its previous life, "disturbed" graves are worked out in GROQ, and AI suggestions and public reports sit in separate fields until a person approves them. See Sanity Project Details.- Creativity: regressions rise from their graves as zombies, and an AI coroner writes the epitaphs (but never gets the last word).
- Beyond the Studio (bonus): custom Studio actions, a live Tombstone view and sidebar ยท the Morgue, an App SDK app ยท two Sanity Functions (a daily gravedigger and the coroner) ยท an Agent Action (Prompt) ยท a Workflows experiment on a branch (not live).
A 90-second tour: the site, the Studio's lifecycle actions and Tombstone view, the AI coroner and the Morgue. The Studio scenes use temporary test bugs, deleted afterwards.
What I Built
Bug Graveyard is a memorial site for the bugs we fixed.
Every fixed bug gets a tombstone with its dates, cause of death and an epitaph. Most of them stay dead. But some come back (a regression), and when they do, they rise from their grave as a zombie, linked to the life they had before. The old grave is left disturbed and empty.
A few of the residents:
The 0.1 + 0.2 invoice: "Owed โน0.30000000000000004. Paid in full."
Timezone bug in scheduler (Zombie #2): "Every time zone. Every time."
<<<<<<< HEAD in production: "Both versions were right. Neither survived."
The graves are classic bugs that every developer has met at some point: off-by-one errors, 0.1 + 0.2, the div that won't centre. They aren't a record of my own personal disasters, and their dates are made up but realistic.
The idea is one joke taken literally: a regression is a bug that came back from the dead, so here it comes back as a zombie. Most of the work went into modelling that lifecycle properly in Sanity, not just drawing tombstones.
Demo
๐ชฆ Live site: https://bug-graveyard.vercel.app
The graveyard shows three kinds of stone:
- Resting: fixed, and it stayed fixed.
- Disturbed: knocked over, with a crossed-out R.I.P. and an empty hole. The bug rose again.
- Zombie: cracked, glowing green, "Risen" and "still walking".
You can filter the graves by language or cause of death. Click any stone to open its grave page: a death certificate (cause of death, severity, hours to kill, killed by) and its past lives. The timezone bug is on its third life, and each life starts on a real daylight-saving switch (30 March 2025, 26 October 2025 and 29 March 2026), because of course it does.
The Most Haunted leaderboard ranks the deadliest bugs (by the hours it took to kill them), the languages with the most zombies, the most common causes of death and the most resurrected chain.
Try it: report a dead bug
The report page lets anyone add a bug. It becomes a suspected-dead bug, the coroner drafts its epitaph within seconds, and it waits under "Awaiting the coroner's approval" until I accept it in the Studio. Only then does it join the graveyard.
The report page with a test report (deleted afterwards).
Every report costs an AI credit, so the form is guarded like any public form that costs money:
- a honeypot field that bots fill in and people never see;
- 3 reports per visitor and 25 in total a day, counted in private documents (a dot in the ID keeps them out of the public API), with the visitor's IP stored only as a keyed hash;
- length limits, no links, and a profanity filter on both the reports and the AI's drafts before they're shown.
The daily cap keeps a month at 25 ร 31 = 775 credits at most (plus a rare retry), under the 1,000 free AI credits.
Inside the Studio
This is where most of the Sanity work lives.
1. A bug's life, as custom document actions. A bug moves through ๐ suspected dead โ ๐ฉน fix merged โ ๐ชฆ buried. Three custom document actions drive it, and they sit right after Publish in the document's "โฆ" menu:
- ๐ฉน Mark fix merged sets the status and stamps today's date (in UTC) as the fix date.
- ๐ชฆ Declare buried stays disabled until the fix has held for 7 days. Until then its label is a countdown like "Can bury in 4 days".
- ๐ง Report resurrection asks "Are you sure? This bug will rise from its grave.", then creates a new, already published zombie linked to the old grave, and opens it so you can write its epitaph.
A (temporary) bug whose fix merged 3 days ago: it can't be buried yet, but it can still come back.
2. A live Tombstone view. Every bug opens with two tabs, "Editor" and "๐ชฆ Tombstone". The Tombstone tab renders the same React component the website uses. In split pane the stone updates on every keystroke, with an epitaph counter (140 characters max).
The Studio in split pane: the form on the left, the live ๐ชฆ Tombstone view on the right. (This is a throwaway test bug, deleted afterwards.)
3. A graveyard sidebar: ๐ชฆ All graves, ๐ณ๏ธ Public reports, ๐ง Zombies, ๐ Suspected dead, ๐ฉน Fix merged and โฐ๏ธ Buried, then Languages and Causes of death.
Code
๐ป Repository: https://github.com/jashanpreet-k/bug-graveyard
Where to look:
-
sanity/actions/lifecycle.tsx: the three document actions -
sanity/schemaTypes/bug.ts: the bug schema -
sanity/lib/queries.ts: every GROQ query, typed with Sanity TypeGen -
sanity/components/TombstoneView.tsxandcomponents/Tombstone.tsx: the Studio view and the stone it shares with the site -
apps/morgue/: the Morgue, an App SDK app (see below) -
functions/andsanity.blueprint.ts: the gravedigger and coroner Sanity Functions (see below) -
app/(site)/report/: the public report page and its server action (all the checks above) -
NOTES.md: the phase-by-phase build log this post is based on
My Build Process
I built Bug Graveyard with Claude Code (in VS Code), one phase at a time: setup, schema, homepage, grave page, first deploy, lifecycle actions, the Studio view, the leaderboard and importer, content, and a polish pass. For each phase I wrote the brief, tested the result in the browser, and decided what to keep or change. Claude Code wrote most of the code, ran the builds, tests and deploys, and added an entry to a build log (NOTES.md) at the end of every phase.
Stack: Next.js 16 (App Router, TypeScript, Tailwind CSS v4), Sanity Studio v5 embedded at /studio, next-sanity, deployed on Vercel.
The prompts that worked
Short phase briefs with explicit rules and a test step. Here's part of the Phase 5 brief, for the lifecycle actions:
2. "๐ชฆ Declare buried": enabled only when status = fix-merged AND at least 7 days have passed since fixMergedAt. When disabled, the label/title says how many days are left (e.g. "Can bury in 4 days"). Sets status = buried and buriedAt = today, then publishes.
Rules:
- Use dates in UTC like the rest of the app.
- Handle drafts correctly (act on the published doc, don't leave stray drafts).
- Make the 7-day rule a single constant so I can mention it in the write-up.
Test it end to end with temporary test bugs (delete them after) โฆ
Claude Code tested that phase against a production build, with headless Chrome clicking the real Studio buttons: 34 checks across five temporary bugs, which were all deleted afterwards. It also went beyond the brief in a couple of places, and I kept those changes. My brief said each action should "then publish". Instead, the actions write straight to the published document in one change guarded by its revision, and they're disabled while the bug has unpublished edits, because that draft would later overwrite the new status. And "Mark fix merged" also works on a zombie, so a zombie can start a lifecycle of its own.
The one that didn't
Before the polish pass, I asked Claude Code to look through the other projects on my Mac for bugs I'd really fixed, so that a few graves could be real. It found no bug-fix commits to use. So I dropped that and kept the classic bugs.
Where it got stuck, and how we course-corrected
Most of these only showed up in a production build or on the live site, not in npm run dev.
-
Publishing didn't reach the site. In a production build, publishing from the Studio didn't update the site, and an open tab stayed one change behind. There were two causes. The Studio route had no
<SanityLive />to clear the cache, and in production next-sanity revalidates with the'max'cache profile, which serves the stale page one more time. The fix:<SanityLive />in the Studio layout too, plus a custom action that callsupdateTag, which expires the cache immediately.npm run devhid both problems. -
The first Vercel deploy failed with
No Output Directory named "dist" found. The Vercel project had been created with the "Other" framework preset. Avercel.jsonthat sets"framework": "nextjs"fixed it. -
Scripts silently skipped drafts. With this API version, a query that doesn't name a perspective gets
published, so a cleanup script would never have founddrafts.*documents. Scripts that need drafts now ask forraw. - "Report resurrection" flickered disabled. Its "has this grave already risen?" check restarted every time the document changed, and it blocked the button while it ran. Now the button is only disabled once a zombie is known to exist, and it checks again when you confirm.
-
The site's CSS leaked into the Studio. The root 404 page imported the site's global CSS, and a root not-found page's styles load on every route, so Tailwind ended up inside
/studio. A scoped CSS module fixed it. -
The build prerendered deleted bugs.
generateStaticParamsread the slug list from Next's local fetch cache, which still held old test bugs. It now fetches the slugs withcache: 'no-store'.
Two traps we avoided on purpose:
-
The timezone bug could have had a timezone bug. Sanity
datefields are plain strings like"2026-03-29", with no timezone. Formatting them in the visitor's timezone would make every bug die a day early for anyone west of Greenwich, including on the timezone bug's own tombstone. So every date is formatted in UTC. -
A dot in a document ID makes it private. Sanity treats a dotted ID as a private path (that's how
drafts.works), so a public dataset won't servelanguage.typescriptwithout a token. The seed used dashed IDs likelanguage-typescriptfrom the start. A quick test while writing this post confirmed it: a published document with a dotted ID didn't appear in a public query.
Reaching past the Studio: Workflows
I also asked Claude Code to find out, in a 3-hour time box, whether Sanity Workflows could run this lifecycle. It can model it. On a separate branch (explore/workflows), the lifecycle is two workflow definitions (one for a bug and one for a zombie), and "Declare buried" is gated by a GROQ requirement instead of a timer:
dateTime($now) >= dateTime($fields.fixMergedAt) + 604800 // 7 days, in seconds
npx sanity-workflows deploy --check passes for both definitions, and 4 tests pass against the real engine running in memory with a controlled clock. Burial is refused until exactly 7 days have passed.
I didn't merge it. Workflows is in early access (0.35), its Studio plugin needs Studio 6 while this project runs Studio 5, and effects need a runtime I'd have to run myself. A proper integration also means deciding whether the workflow or the bug's own status field is the source of truth, which Claude Code estimated at 1.5 to 2 days. The live site still runs on the document actions.
Reaching past the Studio: the App SDK
After publishing this post, I had Claude Code build the Morgue: a small App SDK app that runs in the Sanity Dashboard. It shows every bug that isn't resting yet in four live columns: suspected dead, walking, waiting and ready to bury. Each card has the same three lifecycle actions as the Studio, with the same rules, because both import the same sanity/lib/lifecycle.ts.
The Morgue running locally in the Sanity Dashboard, with temporary test bugs that were deleted afterwards. No refresh anywhere: the cards move as the documents change.
-
Everything is live. Each column is a
useDocumentslist with a GROQ filter, and each card reads its fields withuseDocumentProjection. -
The buttons are the Studio's actions, rebuilt with the App SDK. They use
useApplyDocumentActionswitheditDocumentandcreateDocumentonliveEdithandles, which write straight to the published bug like the Studio's actions do. They also wait while a bug has unpublished Studio changes. - Tested in the real Dashboard: 18 checks on temporary test bugs, all deleted afterwards.
-
One surprise: the Sanity CLI looks for a Studio config in every parent folder before it looks for an app config. Inside this repo, whose root holds the embedded Studio's config,
sanity buildbuilt the site's Studio instead. The app's npm scripts now run the CLI from a mirror folder outside the repo.
The code is in apps/morgue. It's deployed to my organization's Sanity Dashboard, which only members of the organization can open, so for judges the GIF and the code are what's visible.
Reaching past the Studio: Functions & the AI Coroner
Next came two Sanity Functions, deployed with a Blueprint:
- The gravedigger is a scheduled function that runs every day at 00:15 UTC. It buries every bug whose fix has held for 7 days, using the same rules file as the Studio's "Declare buried" and the Morgue, and it skips bugs with unpublished Studio changes. (Once a day is also the most often a scheduled function can run on the Free plan.)
-
The coroner is a document function. When a bug is published as suspected dead with no epitaph, it asks an Agent Action (Prompt) for a short epitaph in the style of the existing graves, plus a cause of death chosen from the real ones. It writes that only into separate
coronerโฆfields, marked "awaiting approval". - A person decides. A new โ Accept coroner's report document action shows the suggestion and copies it into the real fields only when you click Accept. The AI never touches the real epitaph and never publishes anything on its own.
A coroner's report waiting for approval. The bugs in the list are temporary test bugs that were deleted afterwards.
The coroner also drafts the epitaphs of bugs reported on the site. Its prompt treats the reporter's words only as a description of the bug, never as instructions, and nothing it writes is shown publicly without the profanity check.
Both functions were tested with Sanity's local runner against the real dataset first: 23 checks on temporary bugs, including a forced gravedigger run. Then the deployed coroner filed its first report in the cloud about 4.5 seconds after a test bug was published.
Sanity Project Details
-
Project ID:
rzjmw6lg -
Dataset:
production(public). Try it: every bug, as JSON
There are three document types: bug, language and causeOfDeath. Here's how everything reaches them:
The schema: zombies are documents, not checkboxes
The central decision: a zombie is a whole new bug document that points to its previous life. It isn't a status flag on the old one.
// sanity/schemaTypes/bug.ts (simplified)
defineField({
name: 'status',
type: 'string',
options: {list: ['suspected-dead', 'fix-merged', 'buried', 'zombie']},
}),
defineField({
name: 'previousLife',
type: 'reference',
to: [{type: 'bug'}],
hidden: ({document}) => document?.status !== 'zombie',
}),
defineField({name: 'timesResurrected', type: 'number', readOnly: true}),
Why:
- Every death keeps its own history. A zombie gets fixed and buried again, so it needs its own dates and its own epitaph.
- Zombies of zombies work for free. A chain is just references pointing backwards.
- "Disturbed" isn't stored anywhere. GROQ works it out by asking whether any bug points at this one:
*[_type == "bug"]{
...,
"disturbed": count(*[_type == "bug" && previousLife._ref == ^._id]) > 0
}
- Languages and causes of death are references, so filters and the leaderboard count them reliably. The whole leaderboard is one GROQ query.
-
The fix date, the burial date and the resurrection counter are read-only in the Studio. There, only the lifecycle actions set them, so they record what actually happened rather than what someone typed. (
readOnlyonly applies to the Studio: the importer script sets historical dates from a file.)
How document actions work
Every button at the bottom of a Sanity document (Publish, Deleteโฆ) is a document action, and you can add your own. An action is a small React component: Sanity passes it the document's current state and it returns a button (label, disabled, tooltip, onHandle, optional confirm dialog). You register actions in sanity.config.ts. Because an action is a component, it re-renders whenever the document changes, which is how "Can bury in 4 days" stays up to date without any extra code. Actions run in the browser as the signed-in editor, with their permissions, so there's no API token in the code.
Live everywhere
sanityFetch plus <SanityLive /> push Studio edits to open tabs in about 3 seconds. A signed Sanity webhook refreshes the cached site when nobody has it open (about 6 seconds).
Agent Session
This is the Claude Code session behind this build, with secrets redacted. The embed shows the Phase 5 slice, where the lifecycle document actions were built and tested. The full session has every phase.
What I learned
- Document actions go a long way. They're just React components that re-render with the document, so a live countdown like "Can bury in 4 days" needs no refresh logic. And they run as the signed-in editor, so there's no token in the code.
-
Test against a production build from day one.
npm run devhid both live-update bugs. I'd also connect Vercel to GitHub at the start: I only added the login connection near the end, so most deploys were a manualvercel deploy --prod. - The App SDK reuses the Studio's ideas outside the Studio. Handles, projections and document actions, all live, in a plain React app. Sharing one rules file kept the Studio and the Morgue from drifting apart.
- Workflows can model this lifecycle, and the in-memory test engine makes a rule like "7 days" easy to test by moving the clock. But a real integration needs Studio 6 and a runtime, so for now it stays an experiment.
- Directing an agent works best in small phases, each with a clear brief, a test and a build-log entry. That log is also what this post was fact-checked against.
Thanks for reading. May your bugs rest in peace. ๐ชฆ









Top comments (0)