Frontend Dev Without a Browser
How we built a visual development workflow where an AI agent edits React code, builds to S3, and uses a remote browser API to see what it made—no Chrome installed.
The Problem
We run an AI coding agent on EC2 that builds and iterates on React games and components. The typical frontend dev loop is: edit code, check the browser, repeat. But our agent runs on EC2. It doesn't have a monitor. It doesn't have a desktop environment. And we really don't want to install Chrome on it.
Headless Chrome eats 500MB–1.5GB of RAM just existing. Our smallest instance tier is a t3.small with 2GB. That's a non-starter. Even on a t3.medium (4GB, ~$30/month), Chrome leaves barely enough room for the agent process, the LLM context, and a Vite build running simultaneously. We'd be paying for RAM that sits idle 90% of the time, waiting for the agent to glance at a webpage.
So we ripped the browser out entirely and built a visual dev workflow that never touches Chrome. Here's how it works.
The Architecture
The loop is simple:
- Edit — The agent modifies React source files in a git worktree
- Build — The framework compiles into static HTML/JS/CSS (Vite ~3s focused, Next.js ~15s full)
- Upload —
aws s3 syncpushes the build output to S3 - Observe — Riddle's remote browser loads the CloudFront URL, takes a screenshot, captures console output
- Post — The agent posts the screenshot and its analysis to a Discord thread
- Repeat
Each round takes 10–15 seconds. The agent typically does 3–5 rounds per task. No local browser. No dev server. No tunnel. Just static files on S3 and an API call to see them.
The Focused Build Trick
A full site build takes ~30 seconds. That kills the iteration loop. You can't do 5 rounds of edit-build-observe if each build is half a minute.
The fix: don't build the whole site. If the agent is editing one component, build only that component. We generate a minimal Vite shell on the fly:
// Generated entry point: src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import Component from '../../actual/path/to/SnakeFight.jsx';
import '../../actual/path/to/SnakeFight.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode><Component /></React.StrictMode>
);This entry point lives in a temp directory alongside a generated vite.config.js
and a barebones index.html. The trick that makes it fast: we symlink the
project's node_modules into the temp directory instead of running npm install.
ln -sf /path/to/project/node_modules /tmp/preview-focused-1234/node_modulesNo dependency resolution. No network requests. The symlink is instant, and the build uses the project's own Vite binary. Result: ~3 seconds from edit to live preview URL.
Why S3 Instead of a Dev Server
The obvious approach for “see what you built” is to run vite dev and
point a browser at localhost:5173. We tried that. Here's why we abandoned it:
Tunnels are fragile. The agent runs on EC2. Riddle's browser runs elsewhere. To connect them, you need a tunnel (ngrok, cloudflared, etc). Tunnels crash. Tunnels have rate limits. Tunnels add latency. Every crashed tunnel is a broken dev loop the agent has to debug instead of doing actual work.
Dev servers hold state. HMR, WebSocket connections, module graph caching—these are great for humans. For an agent doing clean builds every 10 seconds, they're extra complexity for zero benefit. A static S3 upload is stateless and deterministic.
S3 URLs are permanent. The agent can reference them in Discord posts, PR descriptions, and diff comparisons. A
localhostURL dies when the dev server stops.
The upload step is just aws s3 sync ./dist s3://bucket/preview/repo/latest --delete.
The EC2 instance is in us-east-1, same region as the S3 bucket, so the upload
is fast. The total round trip—upload to S3, Riddle fetches and screenshots—adds
a few seconds. Still faster than debugging a crashed tunnel.
CloudFront for Asset Rewriting, Not Caching
Our first version skipped CloudFront entirely—direct S3 URLs, same region, single-digit
millisecond latency. That worked fine for Vite projects where you can set base: './'
and get relative asset paths.
Then we tried it with Next.js. Next's static export produces absolute paths like /_next/static/chunks/abc123.js.
Those resolve to the S3 bucket root, not your preview subdirectory. And unlike Vite, there's
no simple config to make them relative. We needed a solution that works for any framework
without per-project config hacks.
The fix: a single CloudFront Function that rewrites asset paths on the fly. When
a browser loads /preview/repo/label/index.html and that page requests /_next/static/chunks/abc123.js,
the function reads the Referer header, extracts the 3-segment preview prefix, and rewrites
the request to /preview/repo/label/_next/static/chunks/abc123.js.
// CloudFront Function — viewer-request
function handler(event) {
var request = event.request;
var uri = request.uri;
var frameworkPrefixes = ['/_next/', '/__next/', '/_astro/', '/assets/'];
var needsRewrite = false;
for (var i = 0; i < frameworkPrefixes.length; i++) {
if (uri.startsWith(frameworkPrefixes[i])) { needsRewrite = true; break; }
}
if (needsRewrite) {
var referer = request.headers['referer'];
if (referer) {
var match = referer.value.match(/https?:\/\/[^\/]+(\/[^\/]+\/[^\/]+\/[^\/]+)/);
if (match) { request.uri = match[1] + uri; }
}
}
return request;
}This runs at the edge in under a millisecond. No caching—we use the CachingDisabled managed
policy so every request hits S3 fresh. The function exists purely for path rewriting. It handles
Next.js (/_next/), Astro (/_astro/), Vite (/assets/), and
anything else that uses absolute paths. Zero per-project configuration.
CORS: The Non-Issue
CORS trips people up with dev servers and tunnels. Different origins, preflight requests, credential forwarding—it's a whole thing.
With the S3 + CloudFront approach, CORS barely exists. Everything—HTML, JS, CSS—is served from the same CloudFront domain. There are no cross-origin requests. Riddle's browser loads the page like any normal browser would—no special headers, no proxy, no CORS configuration on the bucket.
For Vite projects, you can still set base: './' for relative paths, but
the CloudFront Function handles it either way. For Next.js and other frameworks that insist on
absolute paths, the function rewrites them transparently. The framework doesn't need to
know it's being served from a subdirectory.
Observing Without a Browser
A screenshot tells you what something looks like. But when you're debugging a canvas game or a stateful UI, you need more than pixels. Canvas has no DOM to inspect—it's just a bitmap.
Our solution: console telemetry. Before making visual edits, the agent injects structured logging into the component:
// Injected into the game loop
console.log(JSON.stringify({
_telemetry: true,
frame: frameCount,
player: { x: player.x, y: player.y },
score: state.lastScore,
activeEffects: state.fireflies?.length || 0,
fps: Math.round(1000 / deltaMs)
}));Riddle captures both the screenshot and the console output. The agent parses the
_telemetry entries to verify changes at runtime, not just visually. “Did the
fireflies spawn?” isn't a question about the screenshot—it's
activeEffects > 0 in the JSON.
For interactive testing—clicking buttons, playing through a game, filling forms—the agent writes a Playwright script that Riddle executes remotely:
// Runs on Riddle's remote browser, not locally
await page.goto('https://d1234abcdef.cloudfront.net/preview/game/latest/index.html');
await page.waitForTimeout(2000);
await page.click('.start-button');
await page.waitForTimeout(5000);
await saveScreenshot('gameplay');
// Console output is captured automaticallyThe screenshot and console data come back as files on disk. The agent reads the telemetry, decides if the change worked, and moves to the next round.
Authenticated Pages
Sooner or later your agent needs to screenshot something behind a login. A dashboard, an admin panel, a staging environment with auth. The local Playwright approach is to maintain a browser profile with saved cookies—which works until you have five agents sharing a cookie jar and accidentally invalidating each other's sessions.
With a remote browser, auth is stateless. You pass credentials per request, the browser uses them, the session is gone. No persistent profile accumulating tokens across runs.
Cookie and localStorage Injection
If you already have a session token (from your app's auth flow, a service account, or an API login endpoint), inject it directly:
// Cookie injection — set before navigation
{
"url": "https://app.example.com/dashboard",
"options": {
"cookies": [
{ "name": "session_id", "value": "abc123", "domain": "app.example.com" }
]
}
}
// localStorage injection — for SPAs that read JWTs from localStorage
{
"url": "https://app.example.com",
"options": {
"localStorage": { "auth_token": "eyJhbGciOi..." }
}
}Cookies get injected into the browser context before navigation. For localStorage,
call await injectLocalStorage() in your script after navigating to the origin—the
browser needs to be on the domain before localStorage is accessible.
Login Flows
Sometimes you don't have a token—you need to actually fill in a login form. Steps mode handles simple logins in JSON:
// Steps mode — JSON, no code
{
"steps": [
{ "goto": "https://app.example.com/login" },
{ "fill": ["#email", "agent@example.com"] },
{ "fill": ["#password", "s3cret"] },
{ "click": "button[type='submit']" },
{ "waitForURL": "**/dashboard" },
{ "screenshot": "authenticated-dashboard" }
]
}For anything more complex—MFA, OAuth redirects, conditional waits, or scraping data after login—script mode gives you full Playwright:
// Script mode — full Playwright control
await page.goto('https://app.example.com/login');
await page.fill('#email', 'agent@example.com');
await page.fill('#password', process.env.APP_PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
// Now do whatever you need behind auth
const metrics = await page.locator('.usage-stats').textContent();
await saveScreenshot('dashboard');
await saveJson('metrics', { usage: metrics });Both run in one browser session, 1–2 seconds. Steps mode is simpler for agents to generate. Script mode is more powerful when you need logic after login.
Ephemeral Sessions = No Secret Sprawl
Each job runs in an isolated container. Your cookies exist for that one request, then the container is gone. No persistent browser profile storing tokens across test runs. No cookie jar growing stale sessions. You send exactly what that request needs, it uses it, it's disposed.
For agents that handle multiple users or rotate between environments, this is a meaningful security property. There's no cross-contamination between sessions, and credentials never persist beyond the job that needed them.
Discord as the UI
The human watching this process doesn't SSH into the EC2 box and tail logs. They watch a Discord thread. After every round, the agent posts:
- The screenshot
- What it changed and why
- Key telemetry (fps, error count, state values)
- What it plans to do next
This serves three purposes. First, the human sees live progress and can intervene if something goes off the rails. Second, it creates a visual changelog—before/after comparisons that end up in the pull request. Third, and this is the subtle one: Discord posts survive LLM context compaction.
When the agent's context window fills up, older messages get compressed. If the agent loses track of its working directory or branch, it checks the Discord thread for the breadcrumb message it posted at the start of the session. The Discord thread is the agent's external memory.
The RAM Math
Here's what this looks like in practice:
| Component | With Local Chrome | With Riddle |
|---|---|---|
| Agent (Node.js) | ~200MB | ~200MB |
| Playwright + Chromium | 500MB–1.5GB | 0 |
| Vite build (peak) | ~200–400MB | ~200–400MB |
| Total peak | 1.2–2.1GB | 400–600MB |
| Minimum instance | t3.medium (4GB) | t3.small (2GB) |
| Monthly cost | ~$30 | ~$15 |
The Vite build is transient—it spikes for 3 seconds during the focused build and drops right back. Chrome is persistent memory pressure. Even when it's “idle,” the Chromium binary, shared libraries, and Playwright runtime are resident in memory.
Our agent resizes its EC2 instance in-place depending on workload—t3.small for chat, t3.medium for coding, t3.large for heavy work. Without Chrome, the small tier is viable for far more tasks. That's the real win: not just cheaper at steady state, but more headroom when it matters.
The Gotchas
It's not all smooth. Here's what bit us:
Absolute asset paths break on bare S3. Frameworks like Next.js emit absolute paths (
/_next/static/...) that resolve to the bucket root, not your preview subdirectory. Settingbase: './'works for Vite but not for every framework. Our fix: a CloudFront Function that reads theRefererheader and rewrites asset requests to include the preview prefix. Works for any framework, zero per-project config.Symlinked
node_modulesand Vite's binary. When you symlinknode_modules,npx vite buildsometimes can't resolve the binary through the symlink. We use the project's own Vite binary directly:node_modules/.bin/vite build. Falls back tonpxif that doesn't exist.Stale assets from previous builds. Without
--deleteonaws s3 sync, old JavaScript and CSS files linger in the S3 prefix. If the HTML references a new hash but the old hash is still there, nothing visibly breaks—but the bucket accumulates garbage. Always--delete.Async script timeouts. Riddle's sync API has a ~28 second ceiling. Games that need longer interaction (loading screens, multi-step gameplay) require async mode: the API returns a job ID, you poll for completion, then fetch screenshots from a CDN. We built automatic fallback—if sync times out, the plugin transparently switches to async polling.
Context compaction eats your working directory. LLM agents have finite context. When older messages get compressed, the agent can lose track of which directory it's working in, what branch it's on, even what it was building. The Discord breadcrumb pattern—posting your working state to an external channel early—is a real reliability technique, not just nice-to-have.
The Full Stack
For reference, here's every piece involved:
- Agent: AI coding agent on EC2 (t3.small/medium/large, resizes on demand)
- Source control: Git worktrees (not separate clones—instant branch switching)
- Build: Vite focused-build shell (3s), Next.js static export (~15s), or full project build (30s)
- Hosting: S3 in us-east-1 behind CloudFront (caching disabled; CloudFront Function rewrites absolute asset paths)
- Browser: Riddle API—screenshots, Playwright scripts, console capture
- Human interface: Discord thread with per-round screenshots and narration
- State: EFS (survives instance resize, stop/start)
- CI:
workspace_commitruns a pre-flight build before pushing, then opens a draft PR
Total cost for the browser part: whatever Riddle charges for the screenshots. At $0.004/job and ~10 screenshots per coding session, that's about 4 cents. Compare that to keeping Chrome warm 24/7 on a bigger instance.
Is This Actually Better?
Honestly? It depends on your constraints.
If you're a human developer with Chrome already open on your laptop, this is overengineered.
Just use vite dev and look at your screen.
But if you're building an AI agent that does frontend work—or running headless CI that needs visual verification—this pattern solves real problems:
- No browser dependency. Your agent's host stays lean. No Playwright install, no Chromium binary, no sandbox configuration, no
--no-sandboxhacks. - No tunnel management. Tunnels are the #1 source of flaky dev loops. S3 uploads are boring and reliable.
- Deterministic builds. Every preview is a fresh Vite build uploaded to a known URL. No HMR state, no stale module graph, no “works in dev but breaks in prod.”
- Security isolation. Untrusted JavaScript runs in Riddle's ephemeral containers, not on your agent's host. No SSRF risk, no sandbox escapes touching your credentials.
The workflow is convoluted, yes. It's a Rube Goldberg machine compared to opening a browser. But every piece exists because the simpler alternative failed in practice—tunnels crashed, Chrome ate RAM, dev servers held state that confused the agent. What's left is the set of tradeoffs that actually survives contact with an autonomous agent doing frontend work on a budget instance. (And yes, we did eventually add CloudFront—not for caching, but for a one-line edge function that fixes absolute asset paths across frameworks. Sometimes the right answer is the one you rejected first.)
Try It
Strip the browser from your agent. Build to S3. Let Riddle be the eyes.