AI app prototype limits: four iframe permissions, not the model
Every other playbook on this topic stops at “context window” or “rate limit.” The cap that actually decides whether your generated app behaves like a real app is the sandbox attribute on the preview iframe. Four tokens. An 800 ms HMR budget. A 390x844 mobile frame. All visible in one file.
What people usually mean by “limits” here
Open the existing playbooks on AI app prototyping and they converge on the same two answers. Either it is the model context window (200K tokens for Claude Sonnet 4.6, 128K for others), or it is the API rate limit (some flavor of requests-per-minute on a free tier). Both are real constraints. Neither one tells you why your prototype’s Stripe redirect just sat there doing nothing, or why the file download you wired up never started, or why window.alert() in the preview returns instantly without showing a dialog.
Those failures are not coming from the model. They are coming from the four-permission sandbox attribute on the iframe that holds your live preview. The sandbox attribute is HTML5’s deny-by-default whitelist for what an embedded document is allowed to do. Anything you do not explicitly allow is blocked, silently, with a single line in the iframe’s own console that nobody reads.
mk0r picks four. That choice is the spine of this page.
One generated app meets four iframe permissions
The four tokens, in source
Here is the literal attribute as it ships, on both the previous-state iframe (kept underneath during a hard reload to avoid a white flash) and the live iframe.
Two things to notice. First, both iframes ship the same attribute, so a hard reload does not change runtime permissions mid-flight. Second, the attribute is hardcoded. It is not a prop, not an env var, not part of the generated project’s config. Whatever the agent writes, it runs inside this exact whitelist.
allow-scripts
Lets the iframe run JavaScript. Without it your generated React app does not boot at all. This token is non-negotiable for any modern framework prototype.
allow-forms
Lets <form action="/foo" method="post"> submissions reach a real handler. Without it the browser cancels the submit and your prototype's contact-form, search bar, or login form silently fails.
allow-popups
Lets window.open() return a real window handle. This is what makes OAuth consent screens, Sign-in-with-Google, Stripe popup checkout, and any 'open in new window' flow actually work in the preview.
allow-same-origin
Keeps the iframe in the parent's effective origin. Without it cookies, localStorage, IndexedDB, and the Vite HMR WebSocket all break with SecurityError. This is the token that makes a real Vite app feel like a real Vite app.
Everything else
Denied by default. The sandbox attribute is a whitelist, so any token mk0r does not include is blocked. That covers top-navigation, modals, pointer-lock, downloads, presentation, orientation-lock, storage-access, and the rest.
What your generated app is allowed to do
With those four tokens, almost every AI-built prototype you can describe boots and runs at full fidelity. The permissions are the smallest set that lets a React + Vite stack with cookies and OAuth popups behave normally.
Allowed in the live preview
- React/Vue/Svelte component trees that ship JavaScript
- Form submissions: contact, search, login, payment-form (non-redirect)
- OAuth popups: Sign in with Google, GitHub, Apple, Microsoft
- Stripe popup checkout (window.open style), PayPal popup flow
- Cookies set by your generated backend or by Firebase Auth
- localStorage, sessionStorage, IndexedDB, Cache API
- Vite HMR WebSocket: edits paint without a full reload
- fetch() and XHR to your own backend running in the same sandbox
- Service workers (if your generated app registers one)
- Web Audio, Canvas, WebGL, the standard rendering stack
What your generated app cannot do
Every browser permission outside the four-token list is denied at runtime, with no banner in mk0r’s UI and no error in the chat thread. The agent has no visibility into the iframe’s console, so the only sign that something failed is the absence of the expected behavior.
Blocked by the sandbox attribute
- window.location.href = '/checkout' (top-level navigation has no allow-top-navigation)
- Stripe redirect-mode checkout that replaces the parent URL
- window.alert(), window.confirm(), window.prompt() (no allow-modals)
- element.requestFullscreen() (no allow-presentation, no allow-orientation-lock)
- element.requestPointerLock() (no allow-pointer-lock; in-browser games miss the mouse)
- <a download> file downloads (no allow-downloads-without-user-activation)
- Web Share API: navigator.share({...}) silently rejects
- Window.print() opens a print dialog the user cannot complete inside the iframe
- Plugins: Flash, Java, Silverlight (no allow-plugins, also nothing supports them anymore)
The 800 ms HMR refresh budget
The other constraint inside the preview component is a timer. After the agent writes a file, the parent bumps arefreshNonceprop. The preview component has 800 milliseconds to receive anhmr:afterpostMessage from the in-iframe Vite bridge. If the message arrives, the timer is cancelled and the previous DOM stays mounted (HMR painted, no flash). If it does not, the preview swaps in a fresh iframe with a cache-busting nonce on the URL.
The number is set to 800 because most React component edits paint in 100 to 400 ms and most CSS-only edits paint in under 200 ms, so 800 covers a slow hot reload while still falling back fast enough that the user does not notice the extra step. The timer fires once. There is no HMR retry; if the bridge missed the edit (because the file change was outside Vite’s watch graph, for example), the hard reload is the only fallback.
One file write becomes one of two refresh paths
Two refreshes, side by side
The same agent edit can take either path depending on whether Vite’s HMR caught the change. Here is what each one looks like in the preview component’s log output, prefixed with[mk0r-preview]in the parent console.
The mobile frame: 390 by 844, hardcoded
The third constant in the preview component is the device frame size. Toggle to mobile mode and the iframe is re-rendered at exactly the iPhone 14 Pro’s portrait dimensions, with no breakpoint inspector and no other device options.
Two implications. First, your prototype’s media queries below 768 px (the typical Tailwind md breakpoint) will activate at 390 px wide; your sm: utilities at 640 px will not, because 390 is below 640. Second, there is no tablet preview: a layout intended for the 600 to 1024 px range will be shown at one of the two ends, not in the middle. If the only thing your prototype gets wrong is its tablet layout, you will not catch it inside mk0r’s preview.
Anchor numbers
What other guides on this topic say, vs. what mk0r ships
The 'context window' framing answers a different question. The 'four iframe tokens' framing answers the one your prototype actually hits.
| Feature | Common writing on this topic | mk0r (constants in source) |
|---|---|---|
| What 'limit' actually means at runtime | Tokens in the model context window | Four sandbox permissions on the live preview iframe |
| Where the cap is documented | Vendor blog post or model card | src/components/phone-preview.tsx, lines 24, 9, 155, 163 |
| Top-level navigation in the preview | Not addressed | Blocked: no allow-top-navigation token |
| OAuth popups (Sign in with Google etc.) | Not addressed | Allowed: window.open() returns a real handle via allow-popups |
| Cookies, localStorage, IndexedDB | Not addressed | Allowed: allow-same-origin keeps the iframe in the parent origin |
| Hot reload after an agent edit | Generic 'iframe reload' description | 800 ms postMessage budget, hmr:after signal, fallback hard reload |
| White flash on hard reload | Common gripe in user reviews | Two-iframe layered swap: previous stays mounted until new onLoad |
| Mobile preview size | Variable, often tablet-sized | Fixed at 390x844 (iPhone 14 Pro Portrait); desktop fills 100% |
| How to verify any of this | Trust the docs | Open DevTools, inspect the iframe, grep the constant names |
How to debug a feature that breaks only inside the preview
The pattern is almost always the same. The agent generated a feature that touches a browser permission outside the four-token list; the feature works in isolation but is silently denied inside the iframe. Here is the order to walk through.
Open DevTools on mk0r and pick the iframe
In the Elements panel, find the <iframe sandbox="allow-scripts allow-forms allow-popups allow-same-origin">. Right-click and select 'Show in iframe context' (Chrome) or right-click in the Console and switch the JavaScript context to that iframe.
Look for a 'Blocked because it is sandboxed' message
Sandbox-blocked actions log a single line in the iframe's own console. Examples: 'Blocked navigation to ... a frame because it is sandboxed and the allow-top-navigation flag is not set.' or 'Ignored attempt to display modal dialog from a sandboxed frame.' That message names the missing token.
Match the missing token to a redesign
If it is allow-top-navigation, replace window.location.href= with a window.open() popup and let the user close it manually. If it is allow-modals, replace alert/confirm/prompt with a real <dialog> element. If it is allow-downloads, replace the hidden anchor click with a Blob URL the user clicks themselves.
If the redesign cannot work, defer to the deployed build
The published version of your app, served from your own domain after the billing entitlement check at /api/billing/status, runs without any iframe at all. Branch on window.parent !== window in your generated code: a stub inside the preview, the real feature in the deploy.
Refresh stuck on the slow path? It is the 800 ms timer
If you see the screen flash and React state reset on every edit, it means HMR is not reaching the iframe. Common cause: the file you edited is outside Vite's watch graph (e.g., a config file, a node_module override). The fallback hard reload is correct; the fix is to scope the edit to the watched directory if you want the fast path.
Why these specific four
The HTML5 sandbox attribute defines about a dozen tokens. mk0r could ship more (and lose some isolation) or fewer (and break common frameworks). The four chosen are the smallest set that lets a real-world Vite + React app with cookies and OAuth popups boot without modification.
allow-scripts is mandatory for any framework. Drop it and your generated app is a static HTML document with no interactivity. allow-same-origin is mandatory for cookies, localStorage, IndexedDB, and crucially for the Vite HMR WebSocket; without it, even a successful prompt produces a preview that cannot persist anything or reload itself. allow-forms is the cheapest way to make <form>submissions go through; allow-popups is what makes OAuth and popup checkout flows feel native instead of broken-by-default.
Everything else (top-navigation, modals, fullscreen, pointer-lock, downloads) is a blast radius mk0r does not need to allow during the prototype phase. Most of those are also reasonable to redesign around in any iframe-hosted app, regardless of who built it.
Want to walk the four tokens in the live codebase?
Book 20 minutes and we will open mk0r.com together, point DevTools at the live preview iframe, and show the exact lines in src/components/phone-preview.tsx where the sandbox attribute and HMR_WAIT_MS are set.
Frequently asked questions
Why focus on the preview iframe instead of model context windows or rate limits?
Because the preview iframe is what decides whether your AI-built prototype actually behaves like a real app at runtime. Context windows and rate limits cap how much the model writes; the iframe sandbox caps what the running app is allowed to do once that code is on the page. mk0r mounts the live preview with exactly four sandbox permissions — allow-scripts, allow-forms, allow-popups, allow-same-origin — defined on lines 155 and 163 of src/components/phone-preview.tsx. Anything outside that four-token whitelist is silently denied. A Stripe checkout redirect that tries top-level navigation will not redirect. A Web Share API call will not pop the share sheet. A Fullscreen API request will not enter fullscreen. None of these are model failures and none of them show up as errors in the chat; they are runtime limits encoded in one HTML attribute that no other guide on this topic mentions.
What does each of the four sandbox permissions actually allow?
allow-scripts lets the iframe run JavaScript at all (without it, your generated React app does not boot). allow-forms lets <form> submissions go through the browser's form handler instead of being silently dropped. allow-popups lets window.open() succeed, so a prototype with an OAuth or 'Sign in with Google' button can pop the consent screen in a new window instead of getting null back. allow-same-origin keeps the iframe in the parent's effective origin so cookies, IndexedDB, localStorage, and the Vite HMR WebSocket all work; without it the app would boot in a unique opaque origin where every storage API throws a SecurityError. Together those four are the smallest set that lets a real React + Vite app, with cookies and OAuth popups, run without modification.
What does the iframe explicitly NOT allow?
Everything not in the four-token list. The HTML5 sandbox attribute is a deny-by-default whitelist, so any token mk0r does not include is blocked. That means: no allow-top-navigation, so window.location='/checkout' or a meta refresh does not navigate the parent tab. No allow-modals, so window.alert(), window.confirm(), and window.prompt() are no-ops that return immediately. No allow-pointer-lock, so a built-in browser game cannot capture the mouse. No allow-orientation-lock, no allow-presentation, no allow-storage-access-by-user-activation. No allow-downloads, so a prototype that triggers a file download with a hidden anchor click will be blocked from initiating the download. If your generated app needs any of these, the limit is the iframe attribute, not the model.
What is HMR_WAIT_MS and why is it 800 ms?
HMR_WAIT_MS is the time mk0r gives Vite's hot module reload bridge to repaint after the agent writes a file, before falling back to a full iframe reload. It is set to 800 ms on line 24 of src/components/phone-preview.tsx. The mechanism: when the agent finishes writing a file, the parent bumps a refreshNonce prop. The preview component arms a timer for HMR_WAIT_MS, listens for an hmr:after postMessage from the in-iframe Vite bridge, and either skips the hard reload (if the message arrives) or cache-busts the iframe URL with a ?_=<nonce> query parameter (if the timer fires first). Eight hundred milliseconds is the budget because most React component edits paint in 100 to 400 ms and most CSS changes paint in under 200 ms, so 800 ms tolerates a slow hot reload while still falling back fast enough that the user does not notice an extra step. There is no way to retry HMR without reloading; the timer fires once.
What does the mobile device frame size mean and where is it set?
DEVICE_SIZE.mobile = { w: 390, h: 844 } on line 9 of src/components/phone-preview.tsx. Those are the CSS pixel dimensions of an iPhone 14 Pro in portrait, which mk0r treats as the canonical mobile preview size. The iframe is rendered at exactly those pixel dimensions in mobile mode; the user's generated app then runs at that resolution and any responsive breakpoints below 768 px will activate. Desktop mode sets DEVICE_SIZE.desktop = null, which switches the iframe to width:100% height:100% — the prototype fills the available preview pane. There is no third 'tablet' mode, which means a prototype that targets the 600 to 1024 px range is shown either at 390 px wide or at the full pane width, never in between. This is the only mobile cap and it is hardcoded; no media query inspector chooses other sizes.
How does the swap between the previous and new iframe work?
Two iframes overlap. When refreshNonce changes and the HMR timer fires without an hmr:after message, the live iframe's nonce becomes the new refreshNonce, but the OLD nonce is kept on a second iframe (prevNonce) layered absolutely behind it. The live iframe loads the new src; while it is loading, the previous iframe is still painted underneath, so the user does not see a blank flash. As soon as the new iframe fires onLoad (line 165), prevNonce is cleared and the previous iframe unmounts. The whole double-iframe trick is in src/components/phone-preview.tsx around lines 150 to 168. Visible in network: the same dev server URL is requested twice with different ?_=<nonce> values for ~50 to 300 ms. This is what makes a hard refresh feel seamless even though it is not actually HMR.
Why does sandbox=allow-same-origin matter so much for a Vite app?
Vite's HMR uses a WebSocket from the iframe back to the dev server. Without allow-same-origin, the iframe runs in a unique opaque origin and the WebSocket either cannot open at all or is treated as cross-origin against the dev server's CORS policy, so HMR breaks immediately. mk0r's dev server lives on port 5173 inside the E2B sandbox (VITE_PORT = 5173 on line 785 of src/core/vm-scripts.ts) and is reverse-proxied to the user's preview URL. Keeping the iframe in the parent origin means cookies set by the prototype are scoped to a real domain, localStorage persists across reloads, and the in-iframe postMessage handler can address window.parent without a window.parent.postMessage origin check failing. Drop allow-same-origin and almost every modern web framework breaks; mk0r keeps it because the alternative is unusable.
Can I change the sandbox attributes on my own preview?
Not from the prompt. The sandbox attribute is rendered by the parent component (src/components/phone-preview.tsx) and is not exposed as a prop or a setting. The agent cannot edit it because the file is outside the prototype's project directory inside the sandbox; it lives in the mk0r app shell, not in the generated app. If your prototype needs a permission outside the four-token list, the working pattern is: detect that you are running inside mk0r's preview by checking window.parent !== window, branch the UI, and in the deployed (non-iframe) build, run the full feature. The published version of your app, served from your own custom domain after billing entitlement, runs without any sandbox at all because it is no longer inside an iframe.
How do I know if my prototype is failing because of the iframe and not the agent?
Open DevTools on the parent page and look at the iframe in the Elements panel. If a feature is silently broken (no chat error, no streaming red banner, the agent insists the code is right), check the console of the iframe itself; sandbox-blocked actions log a message like 'Blocked navigation to ... a frame because it is sandboxed and the allow-top-navigation flag is not set.' That message is the giveaway. The agent has no visibility into runtime browser security errors, so it will not catch them or self-correct. The fix is almost always to redesign the feature for the four allowed permissions or to defer it to the published deploy where the sandbox is gone.
Are there other limits this guide does not cover?
Yes. mk0r's failure surface for one prompt turn is documented in our companion guide on the eleven terminal states (six error kinds plus five model stop reasons). The seven runtime clocks that govern timing — request timeout, TTFT watchdog, sandbox TTL, pool age, tab pause, and two attachment caps — are documented in the one-shot prototype limits guide. This page is specifically about the preview layer, the part of the stack that decides what your generated app is allowed to do once the code is on the page. Three layers, three guides, all checkable in the repo.