DEV Community

Charlie Hu
Charlie Hu

Posted on

Stamp the Run: A Weekend Receipt Book for Agent Side Projects

Weekend agent loops fail in the dark. Tools fire, files change, and the process exits with no record of why. A receipt book — one JSON file per run — is the smallest Saturday-scoped fix that still leaves a working demo.

This write-up is a labeled weekend build log, not a production tracing guide. The code is a self-contained example. It records stop reasons, tool names, and touched paths. It does not replay raw model traffic.

Scope cut for one weekend

In scope:

  • A frozen receipt schema on disk
  • A tiny Python store with open, record_tool, record_file, and close
  • A CLI an agent loop can shell out to
  • Tests that fail if a run ends without a stop reason

Skipped on purpose:

  • Provider token invoices and live cost dashboards
  • OpenTelemetry and distributed trace graphs
  • Encryption at rest and multi-user auth
  • Concurrent writers and an HTTP API

The cut is the product. A receipt that always closes beats a half-finished observability stack.

The receipt schema

Each run writes receipts/<run_id>.json. Fields earn their keep or they stay out.

{
  "run_id": "9f3c1a2e-6b44-4d1a-9c0e-0c7b2d8a1111",
  "started_at": "2026-09-23T10:00:00+00:00",
  "ended_at": null,
  "status": "open",
  "stop_reason": null,
  "tool_calls": [],
  "files_touched": [],
  "call_count": 0,
  "notes": []
}
Enter fullscreen mode Exit fullscreen mode

Rules that keep the file greppable:

  1. status is only open or closed.
  2. close refuses to write when stop_reason is missing.
  3. Tool records store a name, ok/error, and a 200-character detail cap.
  4. File records store a path, an action (read | write | delete), and a dry_run flag.
  5. call_count is a local increment, not a vendor bill.

A cassette records raw model I/O for replay. This receipt is the opposite: a human summary that is safe to commit, grep, and diff.

Working demo: the store

Label: example code, not a published package. Python 3.11+ and the standard library are enough.

# receipt_book.py
from __future__ import annotations

import argparse
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal

ROOT = Path("receipts")
DETAIL_CAP = 200
StopReason = Literal["success", "max_turns", "tool_error", "user_abort", "unknown"]
FileAction = Literal["read", "write", "delete"]


def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _path(run_id: str) -> Path:
    ROOT.mkdir(parents=True, exist_ok=True)
    return ROOT / f"{run_id}.json"


def _load(run_id: str) -> dict[str, Any]:
    path = _path(run_id)
    if not path.exists():
        raise FileNotFoundError(f"no receipt for {run_id}")
    return json.loads(path.read_text(encoding="utf-8"))


def _save(doc: dict[str, Any]) -> None:
    path = _path(doc["run_id"])
    tmp = path.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    tmp.replace(path)


def open_run() -> str:
    run_id = str(uuid.uuid4())
    _save(
        {
            "run_id": run_id,
            "started_at": _now(),
            "ended_at": None,
            "status": "open",
            "stop_reason": None,
            "tool_calls": [],
            "files_touched": [],
            "call_count": 0,
            "notes": [],
        }
    )
    return run_id


def record_tool(run_id: str, name: str, ok: bool, detail: str = "") -> None:
    doc = _load(run_id)
    if doc["status"] != "open":
        raise RuntimeError("cannot record on a closed receipt")
    doc["tool_calls"].append(
        {"name": name, "ok": ok, "detail": detail[:DETAIL_CAP], "at": _now()}
    )
    doc["call_count"] += 1
    _save(doc)


def record_file(run_id: str, path: str, action: FileAction, dry_run: bool) -> None:
    doc = _load(run_id)
    if doc["status"] != "open":
        raise RuntimeError("cannot record on a closed receipt")
    doc["files_touched"].append(
        {"path": path, "action": action, "dry_run": dry_run, "at": _now()}
    )
    _save(doc)


def close_run(run_id: str, stop_reason: StopReason) -> dict[str, Any]:
    doc = _load(run_id)
    if doc["status"] == "closed":
        return doc
    if not stop_reason:
        raise ValueError("stop_reason is required")
    doc["status"] = "closed"
    doc["stop_reason"] = stop_reason
    doc["ended_at"] = _now()
    _save(doc)
    return doc


def main() -> None:
    parser = argparse.ArgumentParser(prog="receipt")
    sub = parser.add_subparsers(dest="cmd", required=True)
    sub.add_parser("open")
    p_tool = sub.add_parser("tool")
    p_tool.add_argument("run_id")
    p_tool.add_argument("name")
    p_tool.add_argument("--ok", action="store_true")
    p_tool.add_argument("--detail", default="")
    p_file = sub.add_parser("file")
    p_file.add_argument("run_id")
    p_file.add_argument("path")
    p_file.add_argument("action", choices=["read", "write", "delete"])
    p_file.add_argument("--dry-run", action="store_true")
    p_close = sub.add_parser("close")
    p_close.add_argument("run_id")
    p_close.add_argument(
        "stop_reason",
        choices=["success", "max_turns", "tool_error", "user_abort", "unknown"],
    )
    args = parser.parse_args()
    if args.cmd == "open":
        print(open_run())
    elif args.cmd == "tool":
        record_tool(args.run_id, args.name, args.ok, args.detail)
    elif args.cmd == "file":
        record_file(args.run_id, args.path, args.action, args.dry_run)
    elif args.cmd == "close":
        close_run(args.run_id, args.stop_reason)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Atomic replace through a .tmp file is the whole durability story. A crash mid-write should not leave half a JSON object.

Saturday commands look like this:

RUN_ID=$(python receipt_book.py open)
python receipt_book.py tool "$RUN_ID" list_dir --ok --detail "src/"
python receipt_book.py file "$RUN_ID" src/app.py write --dry-run
python receipt_book.py close "$RUN_ID" success
cat "receipts/${RUN_ID}.json"
Enter fullscreen mode Exit fullscreen mode

A one-liner then lists every run on Sunday morning:

python - <<'PY'
import json
from pathlib import Path
for p in sorted(Path("receipts").glob("*.json")):
    doc = json.loads(p.read_text())
    print(
        f"{doc['run_id'][:8]} {doc['status']:6} {doc['stop_reason']!s:10} "
        f"calls={doc['call_count']} files={len(doc['files_touched'])}"
    )
PY
Enter fullscreen mode Exit fullscreen mode

Open rows are the ones that died without close. That grep is the debugging workflow.

Wiring a toy agent loop

Label: example loop. The planner is a stub. Swap it for any local or remote model client without touching the receipt schema.

# toy_loop.py
from receipt_book import close_run, open_run, record_file, record_tool

MAX_TURNS = 4
ALLOWED = {"list_dir", "read_file", "dry_write"}


def planner(turn: int) -> dict:
    # Placeholder planner. Real loops call a model here.
    if turn == 0:
        return {"tool": "list_dir", "arg": "src"}
    if turn == 1:
        return {"tool": "dry_write", "arg": "src/app.py"}
    return {"tool": "stop", "arg": "success"}


def run() -> str:
    run_id = open_run()
    try:
        for turn in range(MAX_TURNS):
            step = planner(turn)
            name = step["tool"]
            if name == "stop":
                close_run(run_id, "success")
                return run_id
            if name not in ALLOWED:
                record_tool(run_id, name, False, "blocked")
                close_run(run_id, "tool_error")
                return run_id
            record_tool(run_id, name, True, step["arg"])
            if name == "dry_write":
                record_file(run_id, step["arg"], "write", dry_run=True)
        close_run(run_id, "max_turns")
        return run_id
    except Exception as exc:
        record_tool(run_id, "loop", False, str(exc))
        close_run(run_id, "unknown")
        raise


if __name__ == "__main__":
    print(run())
Enter fullscreen mode Exit fullscreen mode

The loop always closes. Exceptions still write unknown. That is the entire reliability claim for the weekend.

Tests that fail closed

# test_receipt_book.py
from pathlib import Path

import pytest

import receipt_book as rb


def test_close_requires_stop_reason(tmp_path, monkeypatch):
    monkeypatch.setattr(rb, "ROOT", tmp_path)
    run_id = rb.open_run()
    with pytest.raises(ValueError):
        rb.close_run(run_id, "")  # type: ignore[arg-type]


def test_records_then_closes(tmp_path, monkeypatch):
    monkeypatch.setattr(rb, "ROOT", tmp_path)
    run_id = rb.open_run()
    rb.record_tool(run_id, "list_dir", True, "src")
    rb.record_file(run_id, "src/app.py", "write", True)
    doc = rb.close_run(run_id, "success")
    assert doc["status"] == "closed"
    assert doc["call_count"] == 1
    assert Path(tmp_path, f"{run_id}.json").exists()


def test_no_writes_after_close(tmp_path, monkeypatch):
    monkeypatch.setattr(rb, "ROOT", tmp_path)
    run_id = rb.open_run()
    rb.close_run(run_id, "user_abort")
    with pytest.raises(RuntimeError):
        rb.record_tool(run_id, "list_dir", True, "")


def test_dry_write_leaves_a_file_row(tmp_path, monkeypatch):
    monkeypatch.setattr(rb, "ROOT", tmp_path)
    run_id = rb.open_run()
    rb.record_tool(run_id, "dry_write", True, "src/app.py")
    rb.record_file(run_id, "src/app.py", "write", True)
    doc = rb.close_run(run_id, "success")
    assert doc["files_touched"][0]["dry_run"] is True
Enter fullscreen mode Exit fullscreen mode
pytest -q test_receipt_book.py
Enter fullscreen mode Exit fullscreen mode

A missing stop reason is a failed test, not a log line someone might notice later. The last test encodes an invariant: a dry_write tool call should leave a file row, or the receipt is lying.

Decision table: stamp vs skip

Event Stamp on the receipt Skip this weekend
Tool name and ok/error Yes Full stdout dumps
File path and action Yes File contents
Stop reason Yes Secret-bearing stack traces
Local call count Yes Vendor token invoices
Raw model prompt No Cassette / replay fixtures

The table is the scope cut in one place. If a field is not in the left column, it does not ship before Monday.

Failure analysis from a dry run

Three failure shapes show up in side-project agent loops. The receipt book only helps if each shape maps to a row.

  1. Process killed. kill -9 after _save of an open receipt leaves status=open and ended_at=null. Sunday's listing command surfaces those files. Manual close with unknown is allowed; the CLI will not invent a success.
  2. Planner returns an unknown tool. The loop records ok=false, detail blocked, then closes with tool_error. The allowlist stays in the loop, not in the receipt schema.
  3. Dry-write never recorded. tool_calls contains dry_write while files_touched is empty. That mismatch is a bug in the loop, not in the model. The fourth test above is there to catch it.

None of those cases need a trace backend. They need a file that still exists after the shell is gone.

Where a free remote planner fits

Local loops still need a planner when the weekend machine should not host a large model. A remote coding assistant can return the next tool name. The receipt file stays on disk beside the repo.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option. Those two properties matter here for one reason: the planner can live off-laptop while the receipt file, allowlisted tools, and dry-run writes stay local. The schema does not depend on that product. A different remote endpoint works the same way. This account's relationship is product outreach, not an independent benchmark lab.

A practical split for the same weekend constraint:

  1. Keep receipt_book.py and the tool allowlist on the laptop.
  2. Send the planner only a short state card: last tool, last error, remaining turns.
  3. Write whatever the planner returns onto the receipt before executing it.
  4. Close the receipt even if the remote call fails.

Free remote capacity is optional fuel, not the architecture. The receipt still closes if the network does not.

What this weekend skipped

No token accounting against a vendor bill. call_count is a local counter. No redaction beyond the 200-character cap, so secrets can still leak through detail. No concurrent writers: two processes sharing one run_id can clobber records. No HTTP surface and no UI. The CLI is the API. cat is the UI.

Skipping those items is how the demo stays runnable before Monday. Adding any one of them usually consumes the rest of the weekend.

Limitations

The store is a JSON file. It is not a ledger. Clock skew, a kill after the .tmp write but before replace, and copied receipts across machines are all unhandled. Detail truncation hides the exact error that mattered. Call counts are not money.

The toy planner is a stub. It does not prove model quality, latency, or safety. Dry-run flags are advisory. A loop that ignores dry_run=True will still write to disk.

Who should not use this approach

  • Teams that already emit structured traces for every tool call
  • Agents that write production data or customer files
  • Anyone who needs a legally complete audit log
  • Multi-tenant services
  • Workflows that treat a receipt as authorization to re-run destructive tools

Those cases need a sandbox, a real allowlist, and an observability pipeline. A closed JSON receipt will not make an agent safe. It will make Sunday debugging shorter. That is the only claim this weekend build makes.

Top comments (0)