When vibe coded apps break, mk0r has 800 milliseconds to catch it
Most writing about this question stops at the abstract: AI generates buggy code, you should be careful, expect to debug. That is true and not useful. Below is the actual catch-and-recover loop in mk0r, named in source. Three files, one numeric constant, one event name, one rule about a blank page. The shape is small enough to verify end-to-end.
When a vibe-coded app breaks in mk0r, the Vite HMR client inside the iframe emits vite:error and the bridge at src/core/vm-scripts.ts:1577 forwards { source: 'mk0r-preview', type: 'hmr:error', message } to the parent page. The parent waits up to 800 milliseconds (HMR_WAIT_MS at src/components/phone-preview.tsx:24) for a successful repaint, then hard-reloads the iframe. The agent reads back the same error through browser_console_messages and fixes the file inside the same turn. If a broken state still ships, every turn is a per-turn git commit at src/core/e2b.ts:1649, so undo is byte-exact.
The three failure shapes
Almost every break in a vibe-coded session collapses into one of three shapes. None of them are exotic. Naming them is the first step, because the catch in mk0r is a different mechanism for each.
- Loud break. A runtime error throws inside the iframe. Vite's overlay appears and Vite emits
vite:error. This is the easiest shape to catch. The bridge postshmr:errorto the parent and the rolling buffer keeps the message for the next turn. - Silent break. The bundler is happy, the page renders, but a new component is not actually on screen because nothing imports it from
App.tsx. No error fires. The page just stays the same or goes blank. This is the shape the global CLAUDE.md addresses with one specific instruction. - Stuck break. HMR never settles. Maybe the bundler is mid-rebuild, maybe a websocket reconnect is in flight, maybe the dev server is paused. The 800 millisecond timeout is the safety net for this shape: the iframe hard-reloads with a new nonce and the user sees the current bundle, broken or not.
“HMR_WAIT_MS = 800 at src/components/phone-preview.tsx line 24. Larger than a healthy HMR cycle, small enough that a stuck preview does not feel hung.”
src/components/phone-preview.tsx
The actual events that flow between the iframe and the parent
The bridge inside the sandbox is a single small block at the bottom of src/core/vm-scripts.ts, around lines 1556 to 1586. It hooks Vite's four client events and translates each one into a parent-window postMessage. The shape is intentional: every message carries source: 'mk0r-preview' so the parent can filter cleanly.
Iframe bridge to parent listener
vite:beforeUpdate
Bridge posts hmr:before with update count
vite:afterUpdate
Bridge posts hmr:after, parent measures paint time
vite:error
Bridge posts hmr:error with the runtime message
vite:beforeFullReload
Bridge posts hmr:full-reload (no recovery, just notice)
Parent onMessage
phone-preview.tsx line 53, filters by source
What the parent page actually logs during a real break
Open the parent devtools while the iframe is running and you can watch this happen on a live session. Every line below is a real shape emitted by code in src/components/phone-preview.tsx and src/core/vm-scripts.ts. The error type is illustrative, the bridge does not invent the message, it forwards whatever Vite hands it.
The full catch-and-fix cycle, one turn end to end
The pieces above only matter if they wire together inside one loop. They do. Here is the actual sequence between a prompt and the next ready-to-prompt state, with file paths so anyone can open the source and verify it.
Prompt fires, the agent edits files in /app/src/
Per the workflow in /root/.claude/CLAUDE.md, the agent edits source files inside the sandbox. Vite's dev server picks up the change and starts an HMR cycle. The iframe inside mk0r.com is pointed at the sandbox's port 5173.
Vite HMR runtime emits vite:beforeUpdate, then either vite:afterUpdate or vite:error
The bridge installed at src/core/vm-scripts.ts lines 1568 to 1585 listens to all four Vite client events. Every event is converted to a postMessage of shape { source: 'mk0r-preview', type, ts, ...extra } and posted to window.parent.
The parent page's onMessage handler runs (phone-preview.tsx)
Line 53 of src/components/phone-preview.tsx attaches a window message listener that filters by source === 'mk0r-preview'. If type is hmr:after and an awaiting flag is set, the parent measures painted-in time and clears the hard-reload timer.
If 800ms passes without hmr:after, the parent hard-reloads
HMR_WAIT_MS = 800 at src/components/phone-preview.tsx line 24. The setTimeout fallback bumps liveNonce to the new value, the iframe URL changes, the iframe reloads from scratch, and the broken state is replaced with whatever the new bundle now produces.
On the next turn, the agent reads browser_console_messages
/root/.claude/CLAUDE.md (src/core/vm-claude-md.ts lines 278 to 283) tells the agent: navigate to http://localhost:5173, take a Playwright snapshot, check browser_console_messages for runtime errors, and do not report completion until the page renders correctly. The rolling logBuffer at src/core/vm-scripts.ts line 261 is what those calls return.
Either way, the turn becomes a git commit
src/core/e2b.ts line 1649 runs git commit -q against /app on every turn that produced a diff. Even if the agent ships a turn that was wrong on a code path it did not exercise, the previous SHA is one git checkout away.
The blank-page rule
The single most important sentence for catching the silent failure mode lives in src/core/vm-claude-md.ts at line 282. The whole sentence is: If the page is blank, verify the component is imported in App.tsx. That sentence is installed into the sandbox at /root/.claude/CLAUDE.md before the agent ever boots, so it is part of the agent's working memory on every turn.
Why dedicate a rule to one symptom? Because the silent failure is the most expensive one to debug without a rule. The bundler has no error to report. The HMR client has no error to forward. The browser has nothing to put in the console. By default the agent would scan the diff, fail to find a bug, and burn a whole turn. The rule shortcuts the search: the answer is almost always that a new component file was created and never rendered from App.tsx. One edit, one HMR cycle, the page is back.
You can read the surrounding workflow at lines 273 to 283 of the same file. It is a four-step browser-testing checklist: navigate to localhost:5173, take a Playwright snapshot, check browser_console_messages, and refuse to report completion until the page actually renders the expected screen.
The undo path, for the cases where the catch missed
No catch loop is perfect. Some breaks only show up on a code path the agent did not exercise during verification. A button works on the first click and crashes on the second, a form renders but a submit handler throws on a value the agent did not type. Those breaks ship. The recovery for them is not inside the 800 millisecond window, it is downstream of it.
On every turn that produced a diff, commitTurn in src/core/e2b.ts runs git add -A followed by git commit -q at line 1649 against the project repo inside /app. The resulting SHA is recorded on the session record. Undo is therefore not a snapshot heuristic and not a diff replay, it is git checkout against a known commit. The bytes you get back are the bytes that were in the tree at the end of the previous turn, exactly.
That is true even after the sandbox has paused (E2B's one hour idle pause, see the iteration-wall guide below), been reaped from the warm pool, or you closed the tab and came back. The next prompt restores from the head SHA in Firestore before the agent runs anything new.
What this does not catch
The honest framing: the catch loop is for a class of breakage, not all breakage. There are three kinds of failure the 800 millisecond window does nothing about, on purpose.
- Logic that looks right and is wrong. A correctly-rendering calculator that adds wrong. An animation that plays at the right time but with the wrong easing. The catch loop only sees what HMR and the browser console can see. Wrong-but- running output requires the user to notice.
- Backend behavior the prototype does not actually have. The sandbox runs a Vite + React frontend. There is no real authentication tier, no production database load, no third- party rate limit to hit. Failures that only appear under those conditions cannot break a vibe-coded session because those conditions are not present.
- Things that broke in a previous session. The console buffer is scoped to the current iframe load. A nasty error from a session two days ago is not still sitting somewhere waiting to be read. That makes the catch loop fast, but it also means a regression introduced and reverted before the current session has no echo.
Why this matters more than the model itself
The dominant story about vibe coding is that it succeeds or fails based on how good the underlying model is. That is part of the truth and not the load-bearing part. Every model fails some percentage of the time. What changes the felt quality of a session is whether the failure is caught inside the same turn or whether it surfaces three prompts later with the user confused about what they did wrong.
The 800 millisecond catch window is small. The console buffer is a few hundred lines. The blank-page rule is one sentence in a configuration file. Each piece on its own is unimpressive. Together they make the difference between a tool that vibe-codes and a tool that vibe-codes and notices.
Want to walk a real break in your own session?
Open mk0r.com, prompt something deliberately wrong, and we can step through the catch loop together.
Frequently asked questions
What does 'when vibe coded apps break' usually mean in practice?
It is the moment after a prompt where the preview should have updated and instead does one of three things: it stays on the previous frame, it goes blank, or it throws a stack trace into the console. The first looks like a slow network. The second looks like a missing component. The third is the loud one. All three are the same underlying event from the iteration loop's point of view: a piece of generated code did not survive the next render, and the question is whether the tool noticed before the user did. In mk0r the noticing happens in three layers, in source: the Vite HMR client posts vite:error to the parent page, the parent waits HMR_WAIT_MS milliseconds for a successful repaint before hard-reloading the iframe, and the agent reads back console.error lines from a rolling buffer in the next turn. None of those are abstract. Each is a few lines of TypeScript that you can open and read.
Where exactly is the 800 millisecond window?
It is the constant HMR_WAIT_MS at src/components/phone-preview.tsx line 24, set to 800. The parent page tracks every refresh request as a nonce. When the nonce bumps, the parent records the time and starts a setTimeout for HMR_WAIT_MS. If the iframe posts hmr:after inside that window, the parent cancels the timer and logs the painted-in time. If 800 milliseconds pass without an hmr:after, the parent gives up on HMR and hard-reloads the iframe with the new nonce in the URL. The hard reload is the visible failure recovery: the preview blanks for a beat and comes back fully. The HMR-survives path is the invisible one: an edit lands, the preview repaints in 220 milliseconds, and the user never thinks about whether anything was ever broken. The 800 number is intentionally larger than a healthy HMR cycle and small enough that a stuck preview does not feel hung.
How does the agent itself learn that something broke?
Through two channels that converge in the same turn. First, the iframe bridge in src/core/vm-scripts.ts at lines 1577 to 1580 listens for Vite's vite:error event and posts a message of shape { source: 'mk0r-preview', type: 'hmr:error', message } up to the parent window via window.parent.postMessage. Second, the same VM startup script at line 268 reassigns console.error so every error string also pushes onto a rolling logBuffer. After a UI change, the agent's instructions in /root/.claude/CLAUDE.md (the source for which is in src/core/vm-claude-md.ts lines 278 to 283) say to navigate via Playwright MCP to http://localhost:5173, take a snapshot, and call browser_console_messages. Whatever was in the buffer at the moment the page loaded is what the agent reads next. If it sees an unresolved error there, it fixes the file before reporting completion. The fix happens inside the same turn the user prompted, not a turn later.
What about the case where the page just goes blank?
Blank is the silent failure mode and the agent's instruction set has a single sentence for it. In src/core/vm-claude-md.ts line 282 the global CLAUDE.md installed into the VM tells the agent 'If the page is blank, verify the component is imported in App.tsx'. That is on purpose. The most common reason a vibe-coded React preview goes blank is that the agent created a new component file but forgot to import and render it from App.tsx. Vite does not throw, the bundler is happy, the page is just a div with nothing inside. Without a written rule the agent burns a whole turn looking for a runtime error that does not exist. With the rule, the diff to App.tsx happens immediately, HMR fires, hmr:after lands inside the 800 millisecond window, and the user sees the new screen.
If a broken state still ships, can it be undone?
Yes, byte-exactly. In src/core/e2b.ts line 1649 the commitTurn helper runs a real git commit -q against the project repo at /app on every turn that produced a diff. The committed SHA is then stored on the session in Firestore. Undo is not a snapshot heuristic, it is git checkout against a known SHA. So a broken render that the agent shipped because something only blew up at runtime can be reverted to the exact bytes of the previous turn, and that is true even after the sandbox paused, was reaped, or the user closed the tab and came back two days later. The next prompt restores from the head SHA before the agent runs anything new.
Why does mk0r use a 800ms HMR wait at all instead of just hard-reloading every time?
Because hard-reloading is visible and HMR survival is not, and the difference between the two compounds across a long iteration session. A successful HMR repaint preserves component state: the open accordion stays open, the form input keeps its value, the scroll position holds. A hard reload throws all of that away. If the preview was hard-reloading on every prompt, the user would lose context constantly and the iteration loop would feel jerky even when the app was actually fine. The 800 milliseconds gives Vite enough headroom for almost every honest HMR cycle, and the timeout is the safety net for the cases where the bundler or the runtime actually failed and HMR is never coming. So the typical successful turn never costs a hard reload, and the typical broken turn pays for one once.
What does this look like from the parent page's console?
Open mk0r.com in a real browser, open devtools, and type a prompt that will cause a real edit. You will see lines prefixed with [mk0r-preview], starting with refresh scheduled and refresh requested with a nonce, then either HMR painted in <N>ms which is the success path or HMR didn't paint within 800ms which is the timeout path. Those log statements are emitted from src/components/phone-preview.tsx around lines 64 to 82. The hmr:error path adds a [mk0r-bridge] vite:error line in the iframe's own console, which the agent later reads through browser_console_messages. The whole loop is observable in real time, from the parent page, by anyone who wants to watch their own session.
When and where vibe coding actually breaks
Related guides
The vibe coding iteration wall is four walls, not one
Four specific failure modes of the iteration loop, each mapped to a file path.
Where vibe coding stops carrying: three handoff seams
Auth, publish, and the one-hour sandbox lifecycle, each named in source.
Vibe coding production limits: three hard ceilings
The constants in mk0r that define what shipping past the prototype actually costs.