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 — Vite compiles the component into static HTML/JS/CSS (~3 seconds)
- Upload —
aws s3 syncpushes the build output to S3 - Observe — Riddle's remote browser loads the S3 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.
Why Not CloudFront?
You might be thinking: “If you're serving from S3, why not put CloudFront in front?”
Because caching is the enemy of rapid iteration.
The agent deploys a new build every 10–15 seconds. CloudFront's default TTL would serve stale content. You'd need to invalidate the cache on every deploy—that's an extra API call, extra latency, and invalidation propagation time across edge locations. For a preview URL that exactly one consumer (Riddle's browser) will fetch, from the same AWS region, CloudFront is pure overhead.
Direct S3 URLs in the same region have single-digit millisecond latency. Good enough.
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 approach, CORS barely exists. The Vite config sets base: './',
making all asset paths relative:
export default defineConfig({
plugins: [react()],
base: './',
build: { outDir: 'dist', target: 'esnext' },
});The built HTML, JS, and CSS all live at the same S3 path. There are no cross-origin API calls.
Riddle's browser loads the page like any normal browser would—no special headers,
no proxy, no CORS configuration on the bucket. The base: './' one-liner is
the entire “CORS strategy.”
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://bucket.s3.amazonaws.com/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 S3. If Vite builds with the default
base: '/', asset references become/assets/index-abc123.js. On S3, that resolves to the bucket root, not your preview subdirectory. The fix isbase: './'for relative paths. One line in the config, but it took a failed build to figure out.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 with a generated focused-build shell (3s) or full project build (30s)
- Hosting: S3 direct URLs in us-east-1 (no CloudFront, no CDN)
- 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, CloudFront cached stale builds, 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.
Try It
Strip the browser from your agent. Build to S3. Let Riddle be the eyes.