DEV Community

Quinn Wang
Quinn Wang

Posted on

Write the Receipt Before the Chat

The one fix I trust for those first fifteen minutes is a local session receipt, written before any agent is allowed to speak. I kept treating the green ready badge as permission, and that habit is what burns the opening quarter hour. Would you let a contractor start painting before you wrote down which room they may enter? I would not, yet I keep opening coding agents with less paperwork than I give a house painter.

The friction showed up as a polite chat box, not as an error, which is why I trusted it for so long. The cursor blinked, the model picker looked alive, and I started describing the bug before I named the tree. Ten minutes later I had three suggestions and no record of the directory those suggestions were allowed to touch. Does a blinking cursor feel like progress to you, or does it feel like a waiter who never wrote the order down?

I used to spend that opening stretch negotiating with the tool about scope, as if conversation could replace a boundary. The agent would offer a patch for a file I had not opened, then ask whether I wanted a broader refactor. I would answer, because silence felt rude, and that answer became the only spec the session had. Have you noticed how a helpful coding tool turns your very first reply into an accidental contract?

The analogy that finally stuck was a coat check, not a dashboard, because dashboards flatter you. You do not get your coat back because the lobby looked elegant; you get it back because someone wrote a number. A coding session needs that same numbered stub before the conversation starts, or the lobby will eat the afternoon. I stopped asking which model felt smarter, and I started asking which stub I could still read after a crash.

What I write down first

The receipt is a small JSON file in the repo, created by a script I run before I open the agent at all. It records the working directory, the branch, the commit, the dirty paths, and the write roots I am willing to allow. It also records the execution surface I chose, because a local shell and a remote server are not the same room. If that file is missing or stale, the guard exits, and the chat never gets a chance to become my spec.

Free access is exactly where my first fifteen minutes get sloppy, because the price tag no longer slows my hand. I open the chat the way I open a sample, and I forget that a sample can still write into a real tree. A free server makes that mistake worse, since the files may land somewhere I cannot see from this laptop. Would you skip the coat check because the cloakroom was complimentary, or would you want the stub even more?

The guard I actually run

The script below is a proposal I have not executed against a live MonkeyCode server in this draft, so treat it as a sketch. I want the guard to be boring, local, and fail-closed, because a clever guard becomes another thing I have to debug at midnight. You can drop the file at the repo root, mark it executable, and run it before you touch the agent UI. If your team already has a workspace contract, keep that contract, and use this only as a personal preflight.

#!/usr/bin/env bash
# proposal: unexecuted preflight. Writes .session-receipt.json, then refuses a stale stub.
set -euo pipefail

root="$(git rev-parse --show-toplevel)"
cd "$root"

surface="${SESSION_SURFACE:-local}"          # local | free-server
write_roots="${SESSION_WRITE_ROOTS:-src,tests}"
max_age="${SESSION_RECEIPT_MAX_AGE:-900}"

if [[ "$surface" != "local" && "$surface" != "free-server" ]]; then
  echo "refusing: surface must be local or free-server" >&2
  exit 2
fi

export SESSION_ROOT="$root"
export SESSION_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
export SESSION_HEAD="$(git rev-parse HEAD)"
export SESSION_STATUS="$(git status --porcelain)"
export SESSION_SURFACE="$surface"
export SESSION_WRITE_ROOTS="$write_roots"
export SESSION_NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
export SESSION_MAX_AGE="$max_age"

python3 - <<'PY'
import json, os
from pathlib import Path
root = Path(os.environ["SESSION_ROOT"])
roots = [p.strip() for p in os.environ["SESSION_WRITE_ROOTS"].split(",") if p.strip()]
if not roots:
    raise SystemExit("refusing: write_roots empty")
receipt = {
    "schema": "session-receipt/v1",
    "written_at": os.environ["SESSION_NOW"],
    "cwd": str(root),
    "branch": os.environ["SESSION_BRANCH"],
    "head": os.environ["SESSION_HEAD"],
    "porcelain": [ln for ln in os.environ["SESSION_STATUS"].splitlines() if ln],
    "surface": os.environ["SESSION_SURFACE"],
    "write_roots": roots,
    "max_age_seconds": int(os.environ["SESSION_MAX_AGE"]),
    "note": "records intent only; this file is not a sandbox",
}
path = root / ".session-receipt.json"
path.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
print(f"wrote {path}")
PY

python3 - <<'PY'
import json
from datetime import datetime, timezone
from pathlib import Path
path = Path(".session-receipt.json")
data = json.loads(path.read_text(encoding="utf-8"))
written = datetime.strptime(data["written_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - written).total_seconds()
if age > data["max_age_seconds"]:
    raise SystemExit(f"refusing: receipt is {int(age)}s old")
print(
    "receipt ok surface=%s roots=%s head=%s"
    % (data["surface"], ",".join(data["write_roots"]), data["head"][:12])
)
PY
Enter fullscreen mode Exit fullscreen mode

I run that script, read the porcelain list out loud, and only then do I allow myself to open the chat. If the status shows a file I did not expect, I stop, because I would have blamed that mess on the model. The free-server label does not prove the server is empty; it only forces me to admit I chose a room I cannot see. Is that a weak guarantee, and is a weak named guarantee still better than a strong feeling?

chmod +x ./session-receipt.sh
git checkout -b scratch/receipt-drill
SESSION_SURFACE=free-server SESSION_WRITE_ROOTS=src,tests ./session-receipt.sh
# I open the agent only after the guard prints "receipt ok".
grep -qxF '.session-receipt.json' .gitignore || printf '%s\n' '.session-receipt.json' >> .gitignore
Enter fullscreen mode Exit fullscreen mode

How the fifteen minutes move

Here is how the first fifteen minutes used to go, and how the receipt changes the middle of that stretch. Minute one was connection theater, minute four was a vague prompt, and minute nine was a diff I could not narrate. With the guard, minute one is the script, and minute four is a prompt that quotes those write roots. By minute nine I still have a diff, but I can match it to a stub instead of reconstructing the chat from memory.

I do not pick the surface by vibe, and I do not pick it by whichever button is already highlighted in the UI. If the change is a typo I can see, I label the surface local and keep the write root narrow. If I want a disposable room, I label the surface free-server, and I still name the roots I expect back. If I cannot name a root, I do not have a task yet, so why open a free path on a foggy wish?

The prompt I paste is dull on purpose, because a colorful prompt is how I smuggle scope past my own guard. I write the branch, the head, the surface, and the roots in the first lines, then I describe the bug in one sentence. I ask the agent to refuse any path outside those roots, knowing full well a sentence is not a sandbox. Would you trust a promise inside a chat more than a file you can diff, or have you already been burned that way?

branch: scratch/receipt-drill
head: <paste from receipt>
surface: free-server
write_roots: src, tests
task: explain the null check in src/parse.js and propose a patch only inside those roots.
If a path falls outside write_roots, stop and say so. Do not create files I did not name.
Enter fullscreen mode Exit fullscreen mode

After the agent answers, I do not accept anything until I rerun git status and compare it with the porcelain I stored. A new path outside the write roots means I stop, even if the patch looks clever, because clever and bounded are different compliments. I then append a one-line note to the receipt by hand, recording whether I applied, rejected, or abandoned the suggestion. That note is the whole retrospective, and it takes less time than rewriting the prompt because I felt impatient.

python3 - <<'PY'
import json
from pathlib import Path
data = json.loads(Path(".session-receipt.json").read_text(encoding="utf-8"))
Path(".session-receipt.porcelain").write_text(
    "\n".join(data["porcelain"]) + "\n",
    encoding="utf-8",
)
PY
git status --porcelain > .session-now.porcelain
diff -u .session-receipt.porcelain .session-now.porcelain || true
Enter fullscreen mode Exit fullscreen mode

Where the stub lies to you

This approach fails when you treat the receipt as a cage, because the file cannot stop a remote process you do not control. Clock skew, a dirty submodule, or a file matched by gitignore can make the stub look cleaner than the tree you actually have. If you are in a live incident with a named owner, skip the ceremony and use the runbook you already trust. If the directory is empty and disposable, the receipt may cost more than the experiment, and you should not pretend otherwise.

I would also skip this if your platform already mints a signed workspace contract before the agent can start. Duplicating that contract in a side file creates two truths, and two truths are how the first fifteen minutes get confusing again. The receipt covers the gap where the UI says ready and your disk has not agreed yet. Does your current tool even let you see that gap, or does the ready badge cover it with a friendly color?

If a free model path and a free server are already available, run the guard on a throwaway branch first. I am not asking you to stay, and I am not claiming the receipt makes any vendor correct. I am asking whether your next fifteen minutes can start with a stub you could still read after the tab crashes. That question is the whole practice, and the model you pick is just the coat you handed over the counter.

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

Top comments (0)