ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Project 4 of 8 600 min

Project: AI web app builder like Lovable

Build a prompt-to-app system where planner, coder and reviewer agents ship a working React app, with an offline Docker sandbox, a safe file-system layer, automatic error feedback and a live preview you refine by chatting.

Words in this lessonAgent · Multi-agent system · Sandbox · Structured output · Least privilege · Pydantic
Agent
A model running in a loop that decides its own next step, uses tools, checks results and keeps going until a task is done. Like a personal assistant you hand a goal, who makes the calls and checks back, rather than one you direct step by step.
Multi-agent system
A system where several agents, each with its own instructions, tools and context, work together on a task. Like a newsroom where an editor assigns reporters to different beats and writes the story from their memos.
Sandbox
An isolated environment where code or an agent can run without reaching your files, secrets or network. Like a test kitchen with its own ingredients and a locked door, so a failed experiment never reaches the main bakery.
Structured output
An AI model's reply forced to follow a fixed shape you choose, so code can read it reliably. Like handing a customer a printed form instead of asking them to write a letter.
Least privilege
Giving each part of a system only the access it needs to do its job. Like giving the delivery rider the key to the dispatch room, not to the safe.
Pydantic
A Python library that checks and converts incoming data to match a model you define. Like a receiving clerk who checks every delivery against the order sheet and sends back the wrong ones.
On this page
  1. Before you start
  2. The brief
  3. Arjun's first attempt: one prompt, one human in the loop
  4. What you will build
  5. Architecture
  6. Following one request through Proof
  7. Tech stack
  8. Build it, milestone by milestone
  9. 1. Prepare a template app inside a Docker sandbox
  10. 2. Build a safe project file system
  11. 3. Plan the app with a planner agent
  12. 4. Write the files with a coder agent
  13. 5. Build in the sandbox and feed errors back
  14. 6. Catch runtime errors in a headless browser
  15. 7. Review against the plan before shipping
  16. 8. Show a live preview and iterate from chat
  17. Done means done
  18. Stretch goals
  19. Common pitfalls
  20. Show it off

The requests had been piling up in Arjun’s inbox for months. The Kothrud store wanted a Diwali hamper pre-order form. The Aundh store wanted a calculator for how many cake slots each oven could handle on a Saturday. The central kitchen wanted a page to log fridge temperatures. Each was small. Each took him most of a day, and there were nineteen of them.

Meera had seen an advert. “There are tools now where you type what app you want and it builds it. Lovable, something like that. Could our store managers do that themselves?”

“Maybe one day,” Priya said. “But I’d like Arjun to build a small one first. Not to replace those tools. To understand them. An AI that writes code and runs it is where everything you’ve learned meets everything that can go wrong.”

Arjun named it Proof, after the stage where dough rises before it goes in the oven. And, he admitted, because the whole point was proving the app actually worked.

Important

What you will ship: a Lovable-style prompt-to-app builder where a planner agent designs a small web app, a coder agent writes it, a sandbox builds and runs it, errors flow back automatically for fixing, a reviewer agent checks it against the plan, and the user sees a live preview they can refine by chatting.

Before you start

This project combines agents, code generation and security. Revisit these first:

The brief

A user describes an app in plain words. Proof turns that into a working, previewable web app, without the user touching code. Scope it tightly, because tight scope is what makes this buildable:

  • Front-end only. A single-page React app with no server and no database. Data can live in the browser.
  • One fixed template. Every app starts from the same Vite + React + TypeScript project, with dependencies already installed.
  • No new packages. The agents may only use what the template has.
  • Everything runs in a sandbox. Code the model wrote never runs directly on your machine.

The user should see progress, a preview, and be able to say “make the button orange” and watch it change.

Arjun’s first attempt: one prompt, one human in the loop

Arjun started the obvious way. He opened a chat with a model and typed: “Write a complete React app for a Diwali hamper pre-order form.” Out came 280 lines in one file. He pasted it into a fresh Vite project and ran the build.

error TS2307: Cannot find module '@/components/ui/button' or its corresponding type declarations.
error TS2307: Cannot find module 'react-router-dom' or its corresponding type declarations.

The model had assumed a component library and a router that his project did not have. He pasted the errors back. The next version imported date-fns. He was about to run npm install on it when Priya, reading over his shoulder, put her hand on the keyboard.

“Stop. You’re letting a text generator choose what software runs on your laptop.”

“It’s a real package.”

“That one is. Models also invent package names that sound real. Attackers have started publishing malware under exactly those invented names, waiting for someone to install them without looking.” She took her hand away. “Go on. How many rounds so far?”

Six rounds and forty minutes later, it built. The form appeared. He clicked Place order, and nothing happened at all. No error, no message. The button was wired to a function that was never written.

“Count the jobs you just did,” Priya said.

He counted on his fingers. “Picked the tech. Copied the code into files. Ran the build. Read the errors. Pasted them back. Decided which packages were safe. Tested it by clicking. Noticed the button was broken.”

“And the model did?”

“Typed.”

She drew a restaurant kitchen. “The head chef plans the menu: what dishes, what goes in each. Line cooks cook. Before any plate leaves, it stops at the pass, where the head chef tastes it. And the kitchen is behind fire doors, so a grease fire doesn’t burn down the dining room.” She tapped the drawing. “You were the waiter, running between the customer and the kitchen carrying complaints. Your job is to build the kitchen so complaints go straight back to the cook, automatically.”

“Planner, coder, reviewer. Sandbox for the fire doors.”

“And the errors?”

“Go straight back to the coder. Not through me.”

This is the misconception Proof dismantles: a smarter model would write it right the first time. No one writes code right the first time, not you, not Priya, not the best model available. The difference between a toy and a tool is whether the code gets run, and whether what happens when it runs flows back into the next attempt. The model is not the product. The loop is.

What you will build

Must-have

  • A Docker image holding the template app with its dependencies preinstalled.
  • A project file system wrapper that only lets agents touch files under src/, and keeps a snapshot of every version.
  • A planner agent that turns the request into a structured plan: files, purpose of each, and acceptance checks.
  • A coder agent that writes complete files from the plan, the current code and any feedback.
  • A build step inside the sandbox, with no network access, whose errors go back to the coder automatically.
  • A runtime check in a headless browser that catches crashes and blank pages.
  • A reviewer agent that checks the code against the plan before the user sees it.
  • A Streamlit UI with chat on the left, a live preview on the right, and an undo button.

Nice-to-have

  • Small edits instead of whole-file rewrites.
  • A screenshot sent to the reviewer so it can judge the layout.
  • Download the project as a zip, or deploy it to a static host.
  • A choice of templates (for example, one with a charting library).

Architecture

flowchart LR
  U["User request"] --> PL["Planner agent"]
  PL --> CO["Coder agent"]
  CO --> FS[("Project file system")]
  FS --> SB["Docker sandbox: build"]
  SB -->|"build errors"| CO
  SB --> RT["Headless browser check"]
  RT -->|"runtime errors"| CO
  RT --> RV["Reviewer agent"]
  RV -->|"issues"| CO
  RV --> PV["Live preview"]
  PV -->|"follow-up request"| CO

Planner agent. Reads the request and writes a plan as data: a title, the files to create and what each is for, and three to six acceptance checks (things a tester could verify by clicking, such as “the total updates when quantity changes”). It never writes code.

Coder agent. Gets the plan, the current files and any feedback, and returns complete new contents for the files it changes. It never runs anything and never decides whether its own work is good.

Project file system. A thin Python class over a folder. It refuses paths outside src/, refuses huge files, and snapshots every version so you can undo.

Docker sandbox: build. A container from your template image runs npm run build, which type-checks and bundles the app. It has no network, limited memory and CPU, and a time limit. Its output (success, or the error log) is the most valuable feedback in the whole system.

Headless browser check. A real browser with no window, driven by Playwright, opens the built app and records uncaught errors, console errors and blank pages.

Reviewer agent. A separate model call with a different job and a stricter prompt. It reads the code, the plan’s acceptance checks and the runtime report, and either approves or lists specific issues.

Live preview. The built app, served on its own port and shown in an iframe beside the chat. Follow-up messages (“make the button orange”) go to the coder as feedback, and the whole loop runs again.

Following one request through Proof

Here is one complete run, traced with the values Arjun logged. The request:

A Diwali hamper pre-order form. Three sizes: small ₹899, medium ₹1,499, large ₹2,499. Pick size and quantity, choose a delivery date between 28 October and 1 November, see the total live, and show an order summary after submitting.

Planner, 8 s. About 900 input tokens and 650 output tokens. The plan lists four files and four acceptance checks:

{"title": "Diwali Hamper Pre-order",
 "files": [{"path": "src/hampers.ts", "purpose": "Hamper sizes and prices"},
           {"path": "src/components/HamperForm.tsx", "purpose": "Size, quantity, date inputs and live total"},
           {"path": "src/components/OrderSummary.tsx", "purpose": "Summary screen after submit"},
           {"path": "src/App.tsx", "purpose": "Switch between form and summary"}],
 "acceptance_checks": ["Total updates immediately when size or quantity changes",
                       "Dates outside 28 Oct to 1 Nov cannot be submitted",
                       "Submitting shows a summary with size, quantity, date and total",
                       "Quantity cannot be zero or negative"]}

Coder, first draft, 45 s. About 3,400 input tokens (plan, template files, rules) and 2,900 output tokens: four files, 210 lines.

Build 1, 14 s. Fails with exit code 2:

src/components/HamperForm.tsx(42,7): error TS2322: Type 'string' is not assignable to type 'number'.

The coder stored the quantity input’s value (always a string in a browser) straight into a number. A classic bug, the kind a junior developer makes too.

Coder, fix 1, 18 s. The error log goes back as feedback: 4,800 input tokens, 1,100 output tokens. It rewrites one file, wrapping the value in Number(...).

Build 2, 11 s. Passes. The bundle is 148 KB.

Runtime check, 3 s. No crashes. One console warning: “Each child in a list should have a unique key prop.”

Reviewer, 8 s. 5,200 input tokens, 400 output tokens. Not approved:

{"approved": false, "issues": [
  {"file": "src/components/HamperForm.tsx",
   "problem": "The date input accepts 2 November; acceptance check 2 says only 28 Oct to 1 Nov.",
   "suggested_fix": "Set min and max on the date input and validate before submit."},
  {"file": "src/components/HamperForm.tsx",
   "problem": "Size options rendered in a list without a key (console warning).",
   "suggested_fix": "Use the hamper id as the key."}]}

Coder, fix 2, 22 s, then build 3 and a clean runtime check. 5,600 input tokens, 1,300 output. The reviewer’s second pass (5,400 input, 150 output) approves.

The totals. Six model calls: about 25,300 input tokens and 6,500 output tokens. At the prices assumed across these projects (20 per million input and output tokens, ₹88 to the dollar), that is about 0.13 = $0.23, roughly ₹20. Wall-clock time: about two and a half minutes, against most of a day by hand.

Note

Pause and think. Output tokens were only about a fifth of all the tokens, yet they were more than half the cost and most of the waiting. What does that tell you about having the coder rewrite whole files?

Show the answer

Output tokens cost several times more than input tokens and are generated one at a time, so they dominate both bill and wait. Rewriting a 200-line file to change one line pays for all 200 lines again. Professional builders switch to small search-and-replace edits for larger files, which shrinks output dramatically. The catch: an edit only applies if the “search” text matches the file exactly, so edits fail more often than rewrites. Start with whole files (simple, reliable) and move to edits as a stretch goal, once you can measure the saving.

Tech stack

Piece Tool Why
Agents Claude (claude-opus-5-5) with structured output Strong at code; plans and reviews come back as typed data
Contracts between agents Pydantic Each handoff is validated, not parsed from prose
App template Vite + React + TypeScript (Node 22) Fast builds, and TypeScript errors make excellent feedback
Sandbox Docker, run via Python’s subprocess Isolation, resource limits, no network, no extra library
Runtime check Playwright (Chromium, headless) Catches crashes the type checker cannot
Preview server Python’s built-in http.server Serves the built files on a separate port
UI Streamlit Chat, status and an iframe in one file
uv add anthropic pydantic playwright streamlit python-dotenv
uv run playwright install chromium

Build it, milestone by milestone

1. Prepare a template app inside a Docker sandbox

Why: every app starts from the same known-good project, so the model only writes your app’s code, never the setup. Installing packages once, into an image, means builds need no network and take seconds instead of minutes.

Create the template once on your machine, then bake it into an image:

npm create vite@latest template -- --template react-ts
cd template && npm install && cd ..
# Dockerfile
FROM node:22-slim
WORKDIR /app
COPY template/package.json template/package-lock.json ./
RUN npm ci
COPY template/ ./
RUN chown -R node:node /app
USER node
docker build -t proof-sandbox .
docker run --rm --network none proof-sandbox npm run build

Working looks like: the last command prints Vite’s build summary (a few files in dist/) with no network at all. That proves the sandbox can build without fetching anything.

Most likely bug: npm ci fails because package-lock.json is missing. Run npm install inside template/ first so the lock file exists, and commit it.

Stuck? Hint

--network none is the most important flag in this project. If a build ever needs the network, something is trying to download code at build time, and you want that to fail loudly. USER node matters too: the container runs as an ordinary user, not root.

2. Build a safe project file system

Why: the coder agent names file paths, and a model-chosen path is untrusted input. Without a guard, "path": "../../.env" overwrites your secrets. The wrapper is also where versions live, which gives you undo for free.

# projectfs.py
import shutil
from pathlib import Path

class ProjectFS:
    def __init__(self, root: Path, template: Path = Path("template")):
        self.root = root.resolve()
        if not self.root.exists():
            shutil.copytree(template / "src", self.root / "src")
            (self.root / "dist").mkdir()
        self.history: list[dict[str, str]] = []

    def _safe(self, rel: str) -> Path:
        path = (self.root / rel).resolve()
        if not path.is_relative_to(self.root / "src"):
            raise ValueError(f"Refusing to touch {rel!r}: only files under src/ may change")
        return path

    def tree(self) -> list[str]:
        return sorted(str(p.relative_to(self.root)) for p in (self.root / "src").rglob("*") if p.is_file())

    def read(self, rel: str) -> str:
        return self._safe(rel).read_text()

    def write(self, rel: str, content: str) -> None:
        if len(content) > 50_000:
            raise ValueError(f"{rel} is suspiciously large ({len(content)} characters)")
        path = self._safe(rel)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content)

    def snapshot(self) -> None:
        self.history.append({f: self.read(f) for f in self.tree()})

    def undo(self) -> None:
        if not self.history:
            return
        last = self.history.pop()
        for f in self.tree():
            if f not in last:
                self._safe(f).unlink()
        for f, content in last.items():
            self.write(f, content)

resolve() turns src/../../.env into its real absolute path, and is_relative_to checks that it is still inside src/. Library-free, and the most important twenty lines for safety in this project.

Working looks like: fs.write("src/App.tsx", "...") works; fs.write("../.env", "x") and fs.write("src/../package.json", "x") both raise ValueError.

Most likely bug: checking the path with string matching, such as rel.startswith("src/"). "src/../package.json" starts with src/ and escapes anyway. Always resolve first, then compare.

Stuck? Hint

Write a tiny test file with five bad paths (.., absolute paths like /etc/passwd, src/../x, a path with a symlink, an empty string) and assert each one raises. Security code deserves tests more than any other code.

3. Plan the app with a planner agent

Why: asking one call to plan and code produces a 280-line single file, as Arjun found. A separate plan gives the coder a map, gives the reviewer a checklist, and gives you something to show the user (“Here’s what I’m going to build”) before spending money on code.

import anthropic
from pydantic import BaseModel

client = anthropic.Anthropic()
MODEL = "claude-opus-5-5"

class FilePlan(BaseModel):
    path: str      # e.g. "src/components/HamperForm.tsx"
    purpose: str

class AppPlan(BaseModel):
    title: str
    summary: str
    files: list[FilePlan]
    acceptance_checks: list[str]  # things a tester could verify by clicking

PLANNER_SYSTEM = """You plan small single-page web apps. The project is Vite + React + TypeScript.
Only react and react-dom are installed; no other packages, no backend, no database.
Style with plain CSS in src/index.css. Keep src/main.tsx as the entry point. Use at most 6 files.
Write 3 to 6 acceptance checks that a tester could verify by clicking."""

def make_plan(request: str) -> AppPlan:
    r = client.messages.parse(model=MODEL, max_tokens=2000, system=PLANNER_SYSTEM,
        messages=[{"role": "user", "content": request}], output_format=AppPlan)
    return r.parsed_output

Show the plan in the UI as soon as it arrives. Users often correct a plan (“actually, no delivery date needed”) far more cheaply than they correct an app.

Working looks like: for the hamper request, three to five files with sensible purposes and acceptance checks that mention the prices, dates and summary.

Most likely bug: vague acceptance checks such as “the app works well”. They give the reviewer nothing to test. Add an example of a good check to the system prompt.

Stuck? Hint

The planner’s constraints (“only react and react-dom”) are your first defence against invented packages. The second comes in milestone 5, when the build fails on any import that does not exist.

4. Write the files with a coder agent

Why: the coder’s whole world is the plan, the current files and the feedback. Keep it that narrow. It does not judge its own work; that is someone else’s job.

class FileEdit(BaseModel):
    path: str
    content: str   # the complete new contents of the file

class CodeChange(BaseModel):
    edits: list[FileEdit]
    notes: str     # one or two sentences on what changed and why

CODER_SYSTEM = """You write React + TypeScript for a Vite project. Return complete file contents.
Only edit files under src/. Never import packages other than react and react-dom.
Never silence errors with @ts-ignore, @ts-expect-error, eslint-disable or the type any.
When given feedback, fix the cause, and change as few files as possible."""

FORBIDDEN = ("@ts-ignore", "@ts-expect-error", "eslint-disable")

def write_code(plan: AppPlan, fs: ProjectFS, feedback: str = "") -> CodeChange:
    files = "\n\n".join(f'<file path="{p}">\n{fs.read(p)}\n</file>' for p in fs.tree())
    prompt = (f"PLAN:\n{plan.model_dump_json(indent=2)}\n\nCURRENT FILES:\n{files}\n\n"
              f"FEEDBACK TO ADDRESS:\n{feedback or '(none, this is the first draft)'}")
    r = client.messages.parse(model=MODEL, max_tokens=8000, system=CODER_SYSTEM,
        messages=[{"role": "user", "content": prompt}], output_format=CodeChange)
    return r.parsed_output

def apply(change: CodeChange, fs: ProjectFS) -> None:
    for e in change.edits:
        if any(word in e.content for word in FORBIDDEN):
            raise ValueError(f"{e.path}: the coder tried to silence an error instead of fixing it")
    fs.snapshot()
    for e in change.edits:
        fs.write(e.path, e.content)

Notice that FORBIDDEN is checked by code, not by asking nicely. The prompt says “never”, and the code makes sure. That pairing (instruction plus deterministic check) is the pattern from Defence in depth.

Working looks like: after the first draft, fs.tree() lists the planned files, each a plausible React component.

Most likely bug: the coder returns a file but forgets to wire it into App.tsx, so it builds fine and never appears. The reviewer will catch this in milestone 7; until then, check by eye.

Stuck? Hint

If you hit the output limit on a big first draft, the structured output is cut off and fails to parse. Raise max_tokens, or ask the coder to write half the files, then the rest in a second call.

5. Build in the sandbox and feed errors back

Why: this is the loop that replaced Arjun-the-waiter. A TypeScript compiler error names the file, the line and the problem: perfect feedback, for free, in seconds.

import subprocess
from dataclasses import dataclass

@dataclass
class BuildResult:
    ok: bool
    log: str

def run_build(fs: ProjectFS) -> BuildResult:
    cmd = ["docker", "run", "--rm",
           "--network", "none", "--memory", "1g", "--cpus", "1", "--pids-limit", "256",
           "-v", f"{fs.root / 'src'}:/app/src:ro",     # code goes in read-only
           "-v", f"{fs.root / 'dist'}:/app/dist",      # built files come out
           "proof-sandbox", "npm", "run", "build"]
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    except subprocess.TimeoutExpired:
        return BuildResult(False, "The build took longer than 120 seconds and was stopped.")
    return BuildResult(r.returncode == 0, (r.stdout + r.stderr)[-4000:])  # the tail holds the errors

def build_until_green(plan: AppPlan, fs: ProjectFS, max_fixes: int = 4) -> BuildResult:
    result = run_build(fs)
    for _ in range(max_fixes):
        if result.ok:
            break
        apply(write_code(plan, fs, feedback=f"The build failed. Fix these errors:\n{result.log}"), fs)
        result = run_build(fs)
    return result

Each flag earns its place: --network none stops downloads and data leaks; --memory, --cpus and --pids-limit stop a runaway build from freezing your machine; :ro means the build cannot rewrite its own source; timeout stops a build that never ends.

Working looks like: break a file by hand (delete a closing brace), run build_until_green, and watch it fix itself in one or two rounds, with each error printed.

Most likely bug: on Linux, the build fails writing to dist with a permission error, because the container’s node user does not own the host folder. Pass --user with your own user id, or make dist writable.

Note

Pause and think. The sandbox has no network. A user asks for “a page showing today’s weather in Pune”. Does the build break? Is the app safe?

Show the answer

The build still works: it only compiles code, and the weather request happens later, when the app runs in a browser. That reveals two separate boundaries. The Docker sandbox protects your server while building. The preview protects the user’s browser while running: serve it from a different port (a different origin) than your UI, so it cannot read your app’s cookies or page. And have the reviewer flag any request to a domain the user did not ask for, because generated code calling unknown servers is how data leaks.

6. Catch runtime errors in a headless browser

Why: an app can compile perfectly and still crash the moment it loads, or render a blank page, or break when you click. The type checker cannot see any of that. A real browser can.

import functools, http.server, threading
from playwright.sync_api import sync_playwright

PREVIEW_PORT = 8600

def serve(fs: ProjectFS) -> None:
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(fs.root / "dist"))
    server = http.server.ThreadingHTTPServer(("127.0.0.1", PREVIEW_PORT), handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()

def runtime_check(url: str = f"http://127.0.0.1:{PREVIEW_PORT}/") -> list[str]:
    problems: list[str] = []
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.on("pageerror", lambda e: problems.append(f"Uncaught error: {e}"))
        page.on("console", lambda m: problems.append(f"console.{m.type}: {m.text}")
                if m.type in ("error", "warning") else None)
        page.goto(url, wait_until="networkidle")
        if not page.inner_text("#root").strip():
            problems.append("The page rendered nothing inside #root.")
        page.screenshot(path="preview.png", full_page=True)
        browser.close()
    return problems

Library APIs change quickly. If a name has moved, check the docs linked below.

Working looks like: for a healthy app, an empty list and a preview.png that looks like your app. Add throw new Error("boom") at the top of a component and the list should contain it.

Most likely bug: a blank page with no errors at all, because the built index.html points to /assets/... and you are serving from a subfolder. Serve dist itself at the root of the port, as above.

Stuck? Hint

To test behaviour, not just loading, turn one acceptance check into a scripted click: fill the quantity box with page.fill, click the button with page.click, and check the total with page.inner_text. A stretch goal is having the reviewer write these scripts.

7. Review against the plan before shipping

Why: compilers check that code is valid, not that it does what was asked. The date range bug in the worked example built perfectly and ran without a single error. Only something that compared the app to the plan could catch it.

class Issue(BaseModel):
    file: str
    problem: str
    suggested_fix: str

class Review(BaseModel):
    approved: bool
    issues: list[Issue]

REVIEWER_SYSTEM = """You are a strict senior reviewer. You did not write this code.
Go through the plan's acceptance checks one by one and decide if the code meets each.
Also flag: console errors or warnings in the runtime report, unused or unwired components,
dangerouslySetInnerHTML, eval, and any network request to a domain the user did not ask for.
Approve only if every acceptance check is met and nothing else is flagged."""

def review(plan: AppPlan, fs: ProjectFS, runtime_problems: list[str]) -> Review:
    files = "\n\n".join(f'<file path="{p}">\n{fs.read(p)}\n</file>' for p in fs.tree())
    r = client.messages.parse(model=MODEL, max_tokens=2000, system=REVIEWER_SYSTEM, output_format=Review,
        messages=[{"role": "user", "content": f"PLAN:\n{plan.model_dump_json(indent=2)}\n\n"
                   f"RUNTIME REPORT:\n{runtime_problems or 'clean'}\n\nCODE:\n{files}"}])
    return r.parsed_output

def build_app(request: str, fs: ProjectFS, max_reviews: int = 2) -> tuple[AppPlan, Review]:
    plan = make_plan(request)
    apply(write_code(plan, fs), fs)
    for _ in range(max_reviews + 1):
        if not build_until_green(plan, fs).ok:
            raise RuntimeError("Could not get the app to build. Showing the last working version.")
        problems = runtime_check()
        verdict = review(plan, fs, problems)
        if verdict.approved and not problems:
            break
        feedback = "\n".join(f"{i.file}: {i.problem} Fix: {i.suggested_fix}" for i in verdict.issues)
        apply(write_code(plan, fs, feedback=feedback + "\n" + "\n".join(problems)), fs)
    return plan, verdict

build_app is the whole orchestra: plan once, then code, build, run, review, repeat, with a hard cap. If the cap is reached, show the user the app and the reviewer’s open issues. Honest is better than stuck.

Working looks like: for the hamper form, the first review finds at least one real issue, and the second approves.

Most likely bug: the reviewer approves everything. It is the same underlying model as the coder, and models are inclined to agree with confident-looking code. Keep its prompt adversarial, make it walk the acceptance checks one by one, and run the deterministic checks (build, runtime) first so it starts from facts.

Stuck? Hint

When Proof gets stuck in a fix loop (fix A breaks B, fix B breaks A), call fs.undo() back to the last version that built, and send the coder both error messages at once with “fix both without reintroducing either”.

8. Show a live preview and iterate from chat

Why: the first version is never what the user meant. “Make the button orange”, “add a field for a gift message”: iteration is the product, and each request reuses the same loop with the user’s words as feedback.

import streamlit as st
import streamlit.components.v1 as components

left, right = st.columns([2, 3])
with left:
    for m in st.session_state.get("chat", []):
        st.chat_message(m["role"]).markdown(m["content"])
    if request := st.chat_input("Describe your app, or what to change"):
        with st.status("Working...") as status:
            # first message: build_app(request, fs); later messages: write_code(plan, fs, feedback=request)
            ...
        st.session_state.version = st.session_state.get("version", 0) + 1
    if st.button("Undo last change"):
        fs.undo()
        run_build(fs)
        st.session_state.version = st.session_state.get("version", 0) + 1
with right:
    v = st.session_state.get("version", 0)
    components.iframe(f"http://127.0.0.1:{PREVIEW_PORT}/?v={v}", height=720, scrolling=True)

Inside st.status, call status.write(...) at each step (“Planner: 4 files”, “Build failed, fixing (1 of 4)”, “Reviewer: 2 issues”). Watching the agents work is half the magic of these tools, and it makes a two-minute wait feel purposeful. The ?v= number changes after each build, which forces the iframe to reload. Library APIs change quickly. If a name has moved, check the docs linked below.

Working looks like: type the hamper request, watch the status steps, see the form appear on the right, type “make the order button orange”, and see it change within a minute.

Most likely bug: the preview does not update after a change. The browser is caching the old page. The changing ?v= query string fixes most cases; if not, check that the build actually rewrote dist.

Stuck? Hint

For follow-ups, decide whether the change needs a new plan. “Make it orange” is feedback for the coder. “Also add a page for corporate orders” changes the plan; run the planner again with the old plan and the new request.

Done means done

  • A one-paragraph request produces a working app in the preview with no human editing code.
  • Build errors are fixed automatically, and you can see each attempt in the status log.
  • A deliberately thrown runtime error is caught and fixed before the user sees it.
  • The reviewer catches at least one acceptance-check failure that compiled cleanly.
  • A follow-up message changes the running app, and undo restores the previous version.
  • Paths outside src/, new package imports and @ts-ignore Paths outside `src/`, new package imports and `@ts-ignore` are all rejected by code.
  • The sandbox runs with no network, memory, CPU and time limits, as a non-root user.
  • Every loop has a cap, and you have logged the cost and time of ten real requests.

Stretch goals

  • Search-and-replace edits for files over 100 lines; measure the output tokens you save.
  • Port the orchestrator to LangGraph, with planner, coder, build, runtime and reviewer as nodes and conditional edges for the loops (LangGraph core).
  • Give the reviewer eyes. Send preview.png as an image so it can comment on layout (Multi-modal agents).
  • Scripted acceptance tests. Have the reviewer write Playwright steps for each acceptance check, then run them.
  • Cloud sandboxes. Swap local Docker for a hosted sandbox service so many users can build at once.
  • Export. Zip the project, or push it to a new GitHub repo.

Common pitfalls

  • Letting the model choose dependencies. Every installed package is code from a stranger. Fix the dependency list in the template and fail the build on anything else.
  • Fixing errors by hiding them. Under pressure, coders (human and model) add @ts-ignore, any, or delete the feature that broke. Block the first two in code; have the reviewer check that every acceptance check is still met.
  • Unbounded loops. Each lap is a minute and about ₹4 to ₹8. Cap fixes, cap reviews, and show the user where it stopped.
  • Context bloat. At twenty files, sending every file to every call gets slow and expensive. Send the file tree plus only the files named in the feedback or plan.
  • Running generated code anywhere but the sandbox. Not “just once to test”. Not on your laptop. That is what the sandbox is for.

Caution

Models sometimes invent plausible package names, and attackers register those names with malicious code inside, hoping someone installs them without checking. Never npm install or pip install a package because a model suggested it. In Proof, the template’s fixed dependency list and the offline build make this impossible by design.

Show it off

  • README. Open with a 30-second GIF: request typed, status steps ticking, app appearing, a follow-up changing it. Then the architecture diagram, a table of ten test requests with pass or fail, average time and average cost, and a “Security” section listing your sandbox flags and file-system rules. That section alone sets you apart.
  • Demo, under two minutes. Build the hamper form, show a build error being fixed in the log, show the reviewer catching the date-range bug, then make a follow-up change live.
  • LinkedIn. “I built a small Lovable-style app builder to learn how they work. The model isn’t the product, the loop is: planner, coder, an offline Docker sandbox, a headless browser and a reviewer. Ten test apps, eight built cleanly, average two and a half minutes and ₹20 each. What surprised me most: …” Real numbers, an honest failure count.

Arjun showed Meera the hamper form being built from a single paragraph, then changed its colour by typing a sentence.

“Explain what just happened. Simply.”

“It’s a small kitchen,” he said. “A head chef plans the dish, a cook makes it, and before the plate goes out, it’s cooked in a separate fire-proof room to see if it burns, then tasted against the order. If anything’s wrong, it goes back to the cook automatically. I used to be the one running back and forth. Now I just watch.”

“Can the store managers use it?”

“Not yet. It only makes small apps that live in the browser, nothing that touches our order system. And someone should still look at what it built before customers use it.” He paused. “But I can clear my nineteen requests in an afternoon.”

Meera was already thinking about something else. “Good, because I need your afternoons. We’re hiring a junior engineer to work with you, and we have a hundred and forty applications.”

Your build checklist

0/8 done

Check your understanding

3 quick questions
1. Why does Proof check model-chosen file paths with resolve() and is_relative_to() instead of rel.startswith("src/")?
2. A user asks for an app that shows live weather. The sandbox builds with --network none. What happens?
3. Which statement best captures the main lesson of this project?