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, andclose - 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": []
}
Rules that keep the file greppable:
-
statusis onlyopenorclosed. -
closerefuses to write whenstop_reasonis missing. - Tool records store a name, ok/error, and a 200-character detail cap.
- File records store a path, an action (
read|write|delete), and adry_runflag. -
call_countis 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()
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"
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
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())
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
pytest -q test_receipt_book.py
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.
-
Process killed.
kill -9after_saveof an open receipt leavesstatus=openandended_at=null. Sunday's listing command surfaces those files. Manual close withunknownis allowed; the CLI will not invent a success. -
Planner returns an unknown tool. The loop records
ok=false, detailblocked, then closes withtool_error. The allowlist stays in the loop, not in the receipt schema. -
Dry-write never recorded.
tool_callscontainsdry_writewhilefiles_touchedis 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:
- Keep
receipt_book.pyand the tool allowlist on the laptop. - Send the planner only a short state card: last tool, last error, remaining turns.
- Write whatever the planner returns onto the receipt before executing it.
- 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)