DEV Community

Dakota Wu
Dakota Wu

Posted on

Capability Tickets That Keep an AI-Drafted Indie API on Local Adapters

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"
}
Enter fullscreen mode Exit fullscreen mode

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
mail 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"],
    )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Run the proof from the repo root.

python prove_ship.py
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode
python -m unittest test_budget.py
Enter fullscreen mode Exit fullscreen mode

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)