A weekend MVP that only runs inside an AI chat session is not shipped. It is a transcript. Solo founders who keep the bill at zero still need a second proof: the same repo boots in a clean directory, with no leftover keys, no billed hosts, and one command.
This article describes a clean-room replay for AI-generated indie backends. The replay is a shell script, a definition-of-done file, and a boot test. The loop is deterministic. It does not need an agent framework.
Why chat-only ships fail on Monday
AI coding sessions hide environment. The model remembers a .env file that never landed in git. A package was installed globally. A tunnel was left open. Monday, the founder clones the repo on a second machine and the process dies on a missing secret.
The failure is not always “the model wrote bad Python.” The failure is missing replay. A paid stack often papers over that gap with managed secrets and always-on hosts. An indie weekend app cannot.
A clean-room replay treats git clone && ./replay.sh as the ship gate. If that gate is red, the product is not public. The bill stays at zero because the test refuses paid dependencies before they boot.
What the replay must prove
The harness checks four claims. Each claim is a process exit code, not a sentence in a README.
- The app starts with an empty environment except
PORTandDATABASE_PATH. - The only datastore is a local SQLite file created at runtime.
- HTTP health and one write path succeed on localhost.
- An import scan finds no billed SDK names the founder did not allow.
The list is short on purpose. Weekend scope dies when the gate grows into a platform checklist.
Files in the repo
Keep the harness next to the app. Do not hide it in an infra/ folder the model can skip.
mvp/
app.py
requirements.txt
done.yml
replay.sh
boot_test.py
allow_imports.txt
done.yml is the contract. The model may edit application code. It does not get to delete the contract.
# done.yml
name: weekend-mvp
port: 8080
database_path: ./data/app.db
required_routes:
- GET /health
- POST /items
max_boot_seconds: 8
forbidden_env:
- AWS_ACCESS_KEY_ID
- STRIPE_SECRET_KEY
- OPENAI_API_KEY
allow_imports_file: allow_imports.txt
allow_imports.txt is equally boring. Boring is the point.
flask
Add a library only when the founder types it. AI patches that add a billed client fail the replay.
Step 1: Isolate a clean tree
replay.sh copies the repo into a temp directory, drops env files, and installs into a fresh virtualenv. The copy is the Monday machine.
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
STAGE="$(mktemp -d /tmp/mvp-replay.XXXXXX)"
trap 'rm -rf "$STAGE"' EXIT
rsync -a --exclude '.git' --exclude '.venv' --exclude '.env*' \
--exclude 'data' --exclude '__pycache__' \
"$ROOT/" "$STAGE/"
cd "$STAGE"
test ! -f .env
test ! -d data
python3 -m venv .venv
# shellcheck disable=SC1091
source .venv/bin/activate
pip install -q -r requirements.txt
export PORT=8080
export DATABASE_PATH="$STAGE/data/app.db"
unset AWS_ACCESS_KEY_ID STRIPE_SECRET_KEY OPENAI_API_KEY || true
python boot_test.py
echo "replay ok"
The script unsets common paid-provider variables. A process that still demands them is not a zero-bill MVP. The trap removes the stage directory even when the boot test fails.
Step 2: Scan imports before bind
boot_test.py reads the allow-list and walks application modules. A single extra import fails the run. The scan is AST-based and conservative. That is acceptable for a weekend gate.
# boot_test.py — labeled example, not a published benchmark
from __future__ import annotations
import ast
import os
import socket
import subprocess
import sys
import time
from pathlib import Path
import urllib.error
import urllib.request
ROOT = Path(__file__).resolve().parent
ALLOW = {
line.strip()
for line in (ROOT / "allow_imports.txt").read_text().splitlines()
if line.strip() and not line.startswith("#")
}
STDLIB_HINTS = {
"json", "os", "sys", "pathlib", "typing", "sqlite3", "datetime"
}
def fail(msg: str) -> None:
print(f"replay fail: {msg}", file=sys.stderr)
raise SystemExit(1)
def scan_imports() -> None:
for path in ROOT.glob("*.py"):
if path.name == "boot_test.py":
continue
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
names: list[str] = []
if isinstance(node, ast.Import):
names = [a.name.split(".")[0] for a in node.names]
elif isinstance(node, ast.ImportFrom) and node.module:
names = [node.module.split(".")[0]]
for name in names:
if name in STDLIB_HINTS or name in ALLOW:
continue
fail(f"import {name!r} in {path.name} is not allow-listed")
def wait_port(port: int, timeout: float) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket() as sock:
sock.settimeout(0.2)
if sock.connect_ex(("127.0.0.1", port)) == 0:
return
time.sleep(0.1)
fail(f"port {port} did not open")
def json_req(method: str, url: str, data: bytes | None = None) -> None:
req = urllib.request.Request(url, data=data, method=method)
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=2) as resp:
if resp.status >= 400:
fail(f"{method} {url} -> {resp.status}")
except urllib.error.URLError as exc:
fail(f"{method} {url} -> {exc}")
def main() -> None:
scan_imports()
port = int(os.environ.get("PORT", "8080"))
db = os.environ.get("DATABASE_PATH", "./data/app.db")
Path(db).parent.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
[sys.executable, "app.py"],
cwd=ROOT,
env={
"PATH": os.environ.get("PATH", ""),
"PORT": str(port),
"DATABASE_PATH": db,
"PYTHONUNBUFFERED": "1",
},
)
try:
wait_port(port, timeout=8)
json_req("GET", f"http://127.0.0.1:{port}/health")
json_req(
"POST",
f"http://127.0.0.1:{port}/items",
data=b'{"name":"replay"}',
)
if not Path(db).is_file():
fail("sqlite file was not created")
finally:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
print("boot test ok")
if __name__ == "__main__":
main()
The child process environment is explicit. No inherited cloud credentials. No leftover VIRTUAL_ENV tricks. If app.py reads a secret from the parent shell, the test goes red.
Step 3: Keep the app inside the contract
The application under test is a short Flask example. It is a template, not a product.
# app.py — example weekend slice
import os
import sqlite3
from pathlib import Path
from flask import Flask, request
PORT = int(os.environ["PORT"])
DB = os.environ["DATABASE_PATH"]
app = Flask(__name__)
def db() -> sqlite3.Connection:
Path(DB).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB)
conn.execute(
"CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT)"
)
return conn
@app.get("/health")
def health():
return {"ok": True}
@app.post("/items")
def create_item():
payload = request.get_json(force=True, silent=True) or {}
name = str(payload.get("name", "")).strip()
if not name:
return {"error": "name required"}, 400
conn = db()
conn.execute("INSERT INTO items(name) VALUES (?)", (name,))
conn.commit()
conn.close()
return {"name": name}, 201
if __name__ == "__main__":
app.run(host="127.0.0.1", port=PORT)
Runtime calls stay on loopback. Persistence stays in one file. There is no object store, no managed queue, and no third-party auth. Those can wait for a paying user.
Pin the dependency so the replay cannot silently float onto a different Flask major.
# requirements.txt
flask==3.0.3
Step 4: Run the gate on every AI patch
Founders who let a model edit the tree should run replay after each patch, not after the session feels done.
chmod +x replay.sh
./replay.sh
A red replay means the patch is not merged. The founder pastes the stderr block back into the coding session. The model then has a mechanical target: make replay.sh print replay ok.
That feedback is cheaper than a cloud preview environment. It also blocks the usual weekend leak: a new import of a billed SDK “just for now.”
Wrap the gate in git if the repo already uses hooks. The hook is optional. The script is not.
# .git/hooks/pre-commit — optional local hook
#!/usr/bin/env bash
set -euo pipefail
./replay.sh
Step 5: Treat the decision table as the agent
Most weekend “agents” are if-statements with extra ceremony. The table below is enough. No planner. No tool-calling loop that can buy a queue.
| Situation | Action |
|---|---|
| Patch only touches copy or CSS | Skip replay |
| Patch adds a Python import | Run the import scan in boot_test.py
|
Patch touches app.py or requirements.txt
|
Run full ./replay.sh
|
| Need a machine without founder dotfiles | Run replay.sh on a second clean host |
| Model asks for Stripe, S3, or a hosted LLM at runtime | Reject the patch; keep the allow-list frozen |
Replay passes and /items writes SQLite |
Ship the slice; stop the session |
Ship today. Keep the bill at zero. Accept the limits. The table encodes that policy in six rows.
Where a second clean host fits
Local replay is enough when the laptop is the only runtime. Some solo founders still want a second machine so Monday is not “works on my leftover venv.”
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode provides free model access and a free server option. Those two facts are the only product claims used here. A founder can generate replay.sh and boot_test.py on the free model path, then execute the same scripts on the free server as the clean room. The server is a second clone, not a production fleet.
The product is optional. The scripts run with stock Python 3 and Flask. Remove the product mention and the gate still holds. Founders who want that second machine without standing up a billed VPS can run ./replay.sh there and inspect the exit code.
One pass is enough for a weekend ship. Do not turn a free host into an always-on agent loop. Empty env, no founder dotfiles, no globally installed packages. If replay passes in that room, the chat session is no longer the runtime.
Limits
The import scan does not understand dynamic imports or importlib. A determined patch can hide a billed client behind a string loader. The boot test does not packet-filter the child. A process that talks to the network after /health returns can still surprise the founder.
SQLite is not a multi-tenant product database. max_boot_seconds: 8 will fail slow laptops. The harness does not measure cost in currency. It only fails closed when the tree looks like it will need a credit card.
None of the snippets are load-test results. They are a ship gate. Flask’s development server is also not a production server. Replace app.run with a pinned WSGI runner only after the replay still passes.
Who should not use this
Teams with a real staging cluster and a secrets manager do not need a temp-dir replay as their primary control. Apps that must call a paid API on day one cannot pass the empty-env rule. Founders who already know the MVP is a prototype inside one notebook should not pretend this gate makes it a company.
The method is for a solo founder who will publish a URL today and refuse surprise invoices tomorrow. The replay is the definition of done. Everything else waits. The ship decision still belongs to the founder.
Top comments (0)