Solo founders can ship an AI-drafted API this week without opening a cloud bill. The constraint has to be mechanical. Every runtime capability is spent from a ticket file that only lists local adapters, and the process refuses to boot when a patch tries to spend a ticket that is not there.
Cloud SDKs are not a later refactor. They are a different product. A weekend MVP that already speaks Postgres, Redis, object storage, and a hosted mail API has left the free envelope even if no invoice has arrived yet.
AI coding tools make that jump cheap to type and expensive to unwind. A model asked to add password reset will reach for a hosted mailer. A model asked to make it production-ready will add a broker. Drafting is not the failure. Calling the draft engineering is the failure. For a one-person ship, engineering means proving that storage, jobs, mail, and files still resolve to adapters that run on a laptop or a single free host.
This write-up records a ticket file, a factory that fails closed, a tiny HTTP service, and a proof command that writes SHIP_PROOF.json. The method is for indie hackers who need to ship today and can accept the limits. It is not a scaling guide.
The ticket file
A capability ticket is a named adapter, not a vendor slogan. One ticket per concern. No aliases that hide a paid client.
Create capabilities.json at the repo root.
{
"version": 1,
"tickets": {
"storage": "sqlite_file",
"queue": "memory_list",
"mail": "maildir",
"blobs": "local_dir",
"jobs": "inline",
"auth": "hmac_cookie"
},
"data_dir": "./data",
"listen": "127.0.0.1:8080"
}
Allowed values stay small on purpose. sqlite_file means one file under data_dir. memory_list means a process-local deque. maildir means write .eml files. local_dir means hashed files on disk. inline means the request thread runs the job. hmac_cookie means a signed cookie with a local secret file.
Anything else is a declined ticket. Declined tickets fail the process at import time, not after a customer click.
Decision table for the first week
Use the table as the review surface. An agent patch that introduces a new row without a human edit to this table is out of scope for a bill-zero ship.
| Concern | Allowed ticket | Forbidden stand-in | First-week reason |
|---|---|---|---|
| storage | sqlite_file |
Postgres, hosted MySQL | one file, zero extra host |
| queue | memory_list |
Redis, SQS, Celery | dies with the process, which is acceptable |
maildir |
SendGrid, SES, Postmark | founder can open the .eml
|
|
| blobs | local_dir |
S3, GCS, R2 | hashed files next to SQLite |
| jobs | inline |
Cloud scheduler, worker pool | no second process to bill |
| auth | hmac_cookie |
Auth0, Cognito, Clerk | local secret file only |
| listen | loopback |
0.0.0.0 publish |
public bind waits for a real user |
The table is the product contract. Code below only enforces it.
Factory that fails closed
The application never constructs SQLite, queues, or mailers ad hoc. It asks a factory. The factory reads the ticket file once and raises BudgetError on mismatch.
# budget.py
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
ALLOWED = {
"storage": {"sqlite_file"},
"queue": {"memory_list"},
"mail": {"maildir"},
"blobs": {"local_dir"},
"jobs": {"inline"},
"auth": {"hmac_cookie"},
}
class BudgetError(RuntimeError):
pass
@dataclass(frozen=True)
class Tickets:
storage: str
queue: str
mail: str
blobs: str
jobs: str
auth: str
data_dir: Path
listen: str
def load_tickets(root: Path) -> Tickets:
raw = json.loads((root / "capabilities.json").read_text(encoding="utf-8"))
tickets = raw["tickets"]
for key, allowed in ALLOWED.items():
got = tickets.get(key)
if got not in allowed:
raise BudgetError(f"ticket {key}={got!r} is not in {sorted(allowed)}")
data_dir = (root / raw["data_dir"]).resolve()
if not str(data_dir).startswith(str(root.resolve())):
raise BudgetError("data_dir escapes the repo")
data_dir.mkdir(parents=True, exist_ok=True)
return Tickets(
storage=tickets["storage"],
queue=tickets["queue"],
mail=tickets["mail"],
blobs=tickets["blobs"],
jobs=tickets["jobs"],
auth=tickets["auth"],
data_dir=data_dir,
listen=raw["listen"],
)
The path check matters. An agent that sets data_dir to /var/lib/postgresql or a remote mount should not boot. The factory is the spend desk. Application code is not allowed to argue with it.
Local adapters only
Keep adapters boring. Boring is the point.
# adapters.py
from __future__ import annotations
import hashlib
import sqlite3
import time
from collections import deque
from pathlib import Path
from typing import Any, Callable, Deque, Dict, Tuple
from budget import BudgetError, Tickets
class Store:
def __init__(self, tickets: Tickets) -> None:
if tickets.storage != "sqlite_file":
raise BudgetError("storage adapter refused")
self.path = tickets.data_dir / "app.sqlite"
self._db = sqlite3.connect(self.path, check_same_thread=False)
self._db.execute(
"CREATE TABLE IF NOT EXISTS notes "
"(id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at REAL NOT NULL)"
)
self._db.commit()
def add_note(self, body: str) -> int:
cur = self._db.execute(
"INSERT INTO notes(body, created_at) VALUES (?, ?)",
(body, time.time()),
)
self._db.commit()
return int(cur.lastrowid)
def list_notes(self) -> list[tuple[int, str, float]]:
rows = self._db.execute(
"SELECT id, body, created_at FROM notes ORDER BY id DESC LIMIT 50"
)
return list(rows)
class MailDir:
def __init__(self, tickets: Tickets) -> None:
if tickets.mail != "maildir":
raise BudgetError("mail adapter refused")
self.dir = tickets.data_dir / "maildir"
self.dir.mkdir(exist_ok=True)
def send(self, to: str, subject: str, body: str) -> Path:
name = f"{int(time.time() * 1000)}_{hashlib.sha256(to.encode()).hexdigest()[:8]}.eml"
path = self.dir / name
path.write_text(
f"To: {to}\nSubject: {subject}\n\n{body}\n",
encoding="utf-8",
)
return path
class MemoryQueue:
def __init__(self, tickets: Tickets) -> None:
if tickets.queue != "memory_list":
raise BudgetError("queue adapter refused")
self._q: Deque[Tuple[str, Dict[str, Any]]] = deque()
def push(self, kind: str, payload: Dict[str, Any]) -> None:
self._q.append((kind, payload))
def drain(self, handler: Callable[[str, Dict[str, Any]], None]) -> int:
n = 0
while self._q:
kind, payload = self._q.popleft()
handler(kind, payload)
n += 1
return n
class BlobDir:
def __init__(self, tickets: Tickets) -> None:
if tickets.blobs != "local_dir":
raise BudgetError("blob adapter refused")
self.dir = tickets.data_dir / "blobs"
self.dir.mkdir(exist_ok=True)
def put(self, raw: bytes) -> str:
digest = hashlib.sha256(raw).hexdigest()
(self.dir / digest).write_bytes(raw)
return digest
Jobs stay inline. The HTTP handler pushes to the memory queue and drains it before the response returns. That is slower under load. It is also free, inspectable, and enough for a first cohort of users who do not exist yet.
HTTP surface that spends tickets
Stdlib is enough for the first route set. No ASGI stack is required to prove the budget.
# app.py
from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
from adapters import MailDir, MemoryQueue, Store
from budget import load_tickets
ROOT = Path(__file__).resolve().parent
TICKETS = load_tickets(ROOT)
STORE = Store(TICKETS)
MAIL = MailDir(TICKETS)
QUEUE = MemoryQueue(TICKETS)
def run_jobs(kind: str, payload: dict) -> None:
if kind == "note_mail":
MAIL.send(payload["to"], "Note saved", payload["body"])
class Handler(BaseHTTPRequestHandler):
def _json(self, code: int, payload: dict) -> None:
raw = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self) -> None:
if urlparse(self.path).path != "/notes":
self._json(404, {"error": "not_found"})
return
rows = [
{"id": i, "body": body, "created_at": ts}
for i, body, ts in STORE.list_notes()
]
self._json(200, {"listen": TICKETS.listen, "notes": rows})
def do_POST(self) -> None:
if urlparse(self.path).path != "/notes":
self._json(404, {"error": "not_found"})
return
length = int(self.headers.get("Content-Length", "0"))
data = json.loads(self.rfile.read(length) or b"{}")
body = str(data.get("body", "")).strip()
to = str(data.get("notify", "")).strip()
if not body:
self._json(400, {"error": "body_required"})
return
note_id = STORE.add_note(body)
if to:
QUEUE.push("note_mail", {"to": to, "body": body})
QUEUE.drain(run_jobs)
self._json(201, {"id": note_id})
def log_message(self, fmt: str, *args) -> None:
return
def main() -> None:
host, port_s = TICKETS.listen.split(":")
if host not in {"127.0.0.1", "localhost"}:
raise SystemExit("listen host is not loopback; ticket refused")
httpd = ThreadingHTTPServer((host, int(port_s)), Handler)
httpd.serve_forever()
if __name__ == "__main__":
main()
Loopback is part of the ticket. Binding 0.0.0.0 is a later decision, made after a real user exists, not after a model suggests a Docker publish port.
Proof command
A ship is not a feeling. It is a file. The proof script boots the app, writes a note, and checks that SQLite plus a maildir file appeared on disk.
# prove_ship.py
from __future__ import annotations
import json
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def wait_port(host: str, port: int, timeout: float = 5.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with socket.create_connection((host, port), timeout=0.2):
return
except OSError:
time.sleep(0.05)
raise SystemExit("server did not bind loopback in time")
def main() -> None:
tickets = json.loads((ROOT / "capabilities.json").read_text(encoding="utf-8"))
host, port_s = tickets["listen"].split(":")
port = int(port_s)
proc = subprocess.Popen([sys.executable, str(ROOT / "app.py")], cwd=ROOT)
try:
wait_port(host, port)
req = urllib.request.Request(
f"http://{host}:{port}/notes",
data=json.dumps(
{"body": "ship today", "notify": "founder@localhost"}
).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=2) as resp:
created = json.loads(resp.read().decode())
with urllib.request.urlopen(f"http://{host}:{port}/notes", timeout=2) as resp:
listed = json.loads(resp.read().decode())
finally:
proc.terminate()
proc.wait(timeout=3)
sqlite_path = ROOT / "data" / "app.sqlite"
mails = list((ROOT / "data" / "maildir").glob("*.eml"))
proof = {
"ok": True,
"note_id": created["id"],
"listed": len(listed["notes"]),
"sqlite_bytes": sqlite_path.stat().st_size,
"mail_files": len(mails),
"listen": tickets["listen"],
"tickets": tickets["tickets"],
}
if proof["listed"] < 1 or proof["mail_files"] < 1:
raise SystemExit("ship proof failed")
out = ROOT / "SHIP_PROOF.json"
out.write_text(json.dumps(proof, indent=2), encoding="utf-8")
print(out.read_text(encoding="utf-8"))
if __name__ == "__main__":
main()
Run the proof from the repo root.
python prove_ship.py
A green run writes SHIP_PROOF.json. Commit that file with the ticket file. The next agent session has to treat both as inputs, not as suggestions.
Guard the ticket file in tests
Add a unit check so a model cannot silently widen ALLOWED without breaking CI.
# test_budget.py
from pathlib import Path
import json
import tempfile
import unittest
from budget import BudgetError, load_tickets
class TicketTests(unittest.TestCase):
def write(self, tickets, data_dir="./data"):
td = Path(tempfile.mkdtemp())
payload = {
"version": 1,
"tickets": tickets,
"data_dir": data_dir,
"listen": "127.0.0.1:8080",
}
(td / "capabilities.json").write_text(json.dumps(payload), encoding="utf-8")
return td
def test_accepts_local_set(self):
root = self.write({
"storage": "sqlite_file",
"queue": "memory_list",
"mail": "maildir",
"blobs": "local_dir",
"jobs": "inline",
"auth": "hmac_cookie",
})
loaded = load_tickets(root)
self.assertEqual(loaded.storage, "sqlite_file")
def test_rejects_postgres(self):
root = self.write({
"storage": "postgres",
"queue": "memory_list",
"mail": "maildir",
"blobs": "local_dir",
"jobs": "inline",
"auth": "hmac_cookie",
})
with self.assertRaises(BudgetError):
load_tickets(root)
def test_rejects_escaped_data_dir(self):
root = self.write({
"storage": "sqlite_file",
"queue": "memory_list",
"mail": "maildir",
"blobs": "local_dir",
"jobs": "inline",
"auth": "hmac_cookie",
}, data_dir="../outside")
with self.assertRaises(BudgetError):
load_tickets(root)
if __name__ == "__main__":
unittest.main()
python -m unittest test_budget.py
Keep this test next to the ticket file. A patch that only edits app.py is cheap to review. A patch that edits budget.py is a spend request.
Where a free coding session fits
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
A solo founder still has to generate the first handlers somehow. MonkeyCode's free model access and free server option can host that generation-and-proof loop without opening a paid cloud account for the coding environment itself. The ticket file and prove_ship.py remain the product. The environment is only a place to run them.
The method does not depend on any one vendor. If the same files run on a laptop, they are doing the job. Use a hosted free session when the laptop is inconvenient, not because a prompt asked for extra capacity.
Limits, and who should skip this
The memory queue vanishes on process restart. Maildir is not deliverability. SQLite will not like multiple writers across machines. Loopback is not a public launch. HMAC cookies are not an identity platform.
Do not use this approach when the product already has paying users who need multi-instance failover. Skip it when mail must leave the box, when blobs must be geo-replicated, or when a compliance review requires a named cloud vendor. Those are different tickets. Buy them after revenue, not after a prompt.
AI-assisted drafts still need a human to keep ALLOWED small. A model can edit budget.py as easily as app.py. The defense is social as well as technical. The ticket file is reviewed like a spend request. The proof file is the receipt.
Ship the local adapters this week. Seal the tickets before the first paid client is discussed. If a free model session is already generating the handlers, run python prove_ship.py in that same tree before any adapter row is widened.
Top comments (0)