A coding agent without a stop condition does not ship an MVP. It accumulates files, SDKs, and cloud assumptions until the weekend is gone. Solo founders who need a same-day slice should cap iterations, cap files, and write a receipt before any merge. The loop can stay useful. It cannot stay unbounded.
This article treats the popular “agent loop” as a local if-statement with a clock. That is enough for an indie weekend. It is not a platform. The artifact is a three-number contract plus a small Python checker that refuses a patch when the loop overruns those numbers.
The failure mode that burns a free weekend
Unconstrained coding loops fail in a predictable order. First they touch too many files. Then they add a client library for a billed API. Then they invent environment variables the founder never agreed to fund. The product idea is still a waitlist or a single form. The repo no longer matches that idea.
Indie timelines do not survive that drift. A founder shipping today needs a halt condition that is cheaper than taste and faster than a full review board. Eight iterations. Three files. An allowlist of imports. Those three numbers are enough to keep a free box honest.
A three-number contract
The contract is a file, not a meeting. Put it at the repo root so every coding pass can read it. Keep the numbers small enough that a human can still open the diff on a phone.
# loop_budget.toml — example contract for a same-day indie slice
max_iterations = 8
max_files_changed = 3
max_new_lines = 250
[allow.imports]
python = ["sqlite3", "json", "os", "pathlib", "http.server", "html"]
[deny.imports]
python = ["boto3", "stripe", "openai", "anthropic", "redis", "psycopg2", "firebase_admin"]
[allow.env]
names = ["APP_PORT", "APP_DB_PATH", "LOOP_RECEIPT_PATH"]
[deny.env_prefixes]
prefixes = ["AWS_", "STRIPE_", "OPENAI_", "ANTHROPIC_", "GOOGLE_APPLICATION_"]
The allowlist is the product. Everything else is a patch the loop must not land. A denied import is a future invoice. A denied env prefix is the same invoice in a different font.
Artifact: a fail-closed receipt checker
The checker below is a local example. It is not a published package. Run it on the working tree after each agent iteration. Exit status 2 means the loop must stop and the founder must write the next slice by hand or start a new contract.
#!/usr/bin/env python3
"""loop_receipt.py — fail-closed budget for weekend AI coding loops."""
from __future__ import annotations
import ast
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path.cwd()
BUDGET = {
"max_iterations": 8,
"max_files_changed": 3,
"max_new_lines": 250,
"allow_imports": {"sqlite3", "json", "os", "pathlib", "http.server", "html"},
"deny_imports": {"boto3", "stripe", "openai", "anthropic", "redis", "psycopg2", "firebase_admin"},
"deny_env_prefixes": ("AWS_", "STRIPE_", "OPENAI_", "ANTHROPIC_", "GOOGLE_APPLICATION_"),
}
STATE_PATH = ROOT / ".loop_state.json"
RECEIPT_PATH = ROOT / "LOOP_RECEIPT.md"
def git(*args: str) -> str:
out = subprocess.check_output(["git", *args], cwd=ROOT, text=True)
return out.strip()
def changed_files() -> list[str]:
raw = git("diff", "--name-only", "HEAD")
return [line for line in raw.splitlines() if line.endswith(".py")]
def added_lines() -> int:
numstat = git("diff", "--numstat", "HEAD")
total = 0
for line in numstat.splitlines():
added, _removed, path = line.split("\t", 2)
if path.endswith(".py") and added.isdigit():
total += int(added)
return total
def load_state() -> dict:
if not STATE_PATH.exists():
return {"iteration": 0, "halts": []}
return json.loads(STATE_PATH.read_text())
def scan_imports(paths: list[str]) -> list[str]:
hits: list[str] = []
for rel in paths:
tree = ast.parse(Path(rel).read_text(encoding="utf-8"), filename=rel)
for node in ast.walk(tree):
names: list[str] = []
if isinstance(node, ast.Import):
names = [alias.name.split(".")[0] for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.module:
names = [node.module.split(".")[0]]
for name in names:
if name in BUDGET["deny_imports"]:
hits.append(f"{rel}: denied import {name}")
return hits
def scan_env_literals(paths: list[str]) -> list[str]:
hits: list[str] = []
for rel in paths:
tree = ast.parse(Path(rel).read_text(encoding="utf-8"), filename=rel)
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
for prefix in BUDGET["deny_env_prefixes"]:
if node.value.startswith(prefix):
hits.append(f"{rel}: denied env prefix {prefix}")
return hits
def write_receipt(state: dict, files: list[str], added: int, violations: list[str]) -> None:
status = "HALT" if violations else "OK"
body = [
f"# Loop receipt ({status})",
f"- time: {datetime.now(timezone.utc).isoformat()}",
f"- iteration: {state['iteration']}",
f"- files_changed: {len(files)}",
f"- lines_added: {added}",
"- files:",
]
body.extend(f" - {path}" for path in files or ["(none)"])
body.append("- violations:")
body.extend(f" - {item}" for item in violations or ["(none)"])
RECEIPT_PATH.write_text("\n".join(body) + "\n", encoding="utf-8")
def main() -> int:
state = load_state()
state["iteration"] += 1
files = changed_files()
added = added_lines()
violations: list[str] = []
if state["iteration"] > BUDGET["max_iterations"]:
violations.append("iteration cap exceeded")
if len(files) > BUDGET["max_files_changed"]:
violations.append("file cap exceeded")
if added > BUDGET["max_new_lines"]:
violations.append("line cap exceeded")
violations.extend(scan_imports(files))
violations.extend(scan_env_literals(files))
write_receipt(state, files, added, violations)
STATE_PATH.write_text(json.dumps(state, indent=2), encoding="utf-8")
if violations:
print("LOOP HALT")
print("\n".join(violations))
print(f"see {RECEIPT_PATH}")
return 2
print(f"LOOP OK iteration={state['iteration']} files={len(files)} added={added}")
return 0
if __name__ == "__main__":
sys.exit(main())
The script is deliberately boring. Boring is the point. An agent loop that needs a vector store to know when to stop is already the wrong loop for a zero-bill slice.
Workflow: eight steps, one halt
- Freeze the product slice in one sentence in
SLICE.md. Example: “email capture, SQLite file, single HTML page, no accounts.” That sentence is the only feature the loop may implement. - Commit a clean tree. The checker diffs against
HEAD, so a dirty baseline makes the file cap lie. - Copy
loop_budget.tomlvalues into the script or load them later. Keepmax_files_changedat three until the slice is live. - Start the coding pass. One iteration equals one patch proposal, not one token stream. Apply the patch to a branch, not to
main. - Run
python3 loop_receipt.py. Read the exit code. Do not negotiate with a2. - Open
LOOP_RECEIPT.md. If the receipt lists a denied import, revert the patch. Do not “just this once.” - Repeat until the slice runs locally with
python3 -m http.serveror a single-file app module. Stop at the first green path, not at polish. - Delete
.loop_state.jsononly after a tagged commit. The next weekend starts at iteration zero with the same caps.
Commands for that sequence stay small.
git status --short
python3 loop_receipt.py; echo $?
cat LOOP_RECEIPT.md
git restore -s HEAD -- . # use only when the receipt says HALT
A founder who skips step 5 will rediscover billed SDKs the hard way. The receipt is the review. There is no second reviewer on a Sunday night.
Makefile pin so the loop cannot “upgrade” itself
Agents like to add tooling. Pin the only legal commands. Anything else is out of contract.
.PHONY: receipt serve halt-if-dirty
receipt:
python3 loop_receipt.py
serve:
python3 -m http.server 8080
halt-if-dirty:
@test -z "$$(git status --porcelain)" || (echo "dirty tree; refuse loop start"; exit 1)
Run make halt-if-dirty receipt after every patch. The agent does not get a deploy target. Deploy is a founder action after the receipt is OK and the slice sentence still matches the UI.
Decision table for halt vs continue
| Signal in the working tree | Action |
|---|---|
| Iteration count above 8 | Halt. Ship or cut scope. |
| More than 3 Python files in the diff | Halt. Split the slice. |
| More than 250 added lines | Halt. The loop is rewriting, not shipping. |
Import of boto3, stripe, or a hosted model SDK |
Halt. That is a bill. |
String literal with AWS_, STRIPE_, or OPENAI_
|
Halt. Same bill, earlier. |
New markdown, HTML, and one app.py only |
Continue if the slice sentence still holds. |
| Tests that need a network | Halt. Local SQLite or nothing. |
The table is the agent. Founders who want a richer planner can add one later. They should not add it before the first paying user, and they should not add it on a free weekend box.
Where a free model path fits
The checker does not care which editor proposed the patch. It cares that the patch stayed inside the contract. Local inference is optional. A hosted free-model path is optional. Either path still needs the receipt, because a free endpoint can still emit a paid architecture.
Some founders prefer not to stand up local inference at all. MonkeyCode is an open-source coding assistant that currently offers free model access and a free server option for this kind of bounded weekend loop. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Those two availability claims are the only product facts used here. This article does not assign model names, hardware, quotas, or duration. Founders who try a hosted path should still run loop_receipt.py on their own tree so a free server cannot smuggle a billed SDK into main.
Token burn is a separate problem from architectural drift. A free model does not make an unbounded loop safe. It only removes one invoice while the loop invents the next one. The iteration cap remains the control.
Limitations
The checker reads Python. It will not catch a shell script that curls a paid API. It will not catch a dependency pulled in a lockfile but never imported. It will not catch infrastructure clicked in a cloud console. AST allowlists are a tripwire, not a security boundary.
Git is required. A copy-paste project without commits has no HEAD and cannot count files honestly. The line cap also ignores generated HTML assets if they are not in the diff. Founders who let an agent dump a frontend bundle will sail under the Python file cap and still waste the day.
Free model access and a free server option can change. Do not plan a company on an unpublished SLA. Do not store customer PII on a shared free host. Do not treat a weekend receipt as an audit log for a regulated workload.
Who should not use this approach
Teams with more than one active committer should use real review, not a three-number file. Products that already bill through Stripe or a public cloud should encode those vendors in an allowlist on purpose, not pretend the deny list still applies. Anyone handling health, payments, or school records needs a threat model this script does not provide.
Founders chasing model quality charts should also skip this. The method optimizes for a shippable slice on a quiet machine. It does not optimize for agent autonomy, long-horizon planning, or multi-tool orchestration. Those loops are a different product. They are rarely a same-day MVP.
Ship the slice, then stop
The useful end state is a running page, a SQLite file, and a receipt that says OK. Close the editor. Tag the commit. Leave .loop_state.json in the tag so the next weekend cannot pretend it is still iteration two. An agent that still wants to “just add auth” has already lost the contract. The founder who honors the halt is the one who still has a zero bill on Monday.
Top comments (0)