DEV Community

Riley Xu
Riley Xu

Posted on

Migration Diary: Rebuild a Tool-Call Ledger Before You Leave a Paid Coding Agent

You do not lose the model first when you leave a paid coding agent; you lose the evidence. The vendor UI kept every tool call, argument blob, and result hash behind a session you cannot export. Rebuild that trail as a local, append-only log before you cut over, or later incidents become folklore. This diary walks a concrete cutover so the leftover history lives in your repo, not in a product you no longer pay for.

Recent discussion treats chat-driven coding as engineering, then wonders why regressions appear after the tab closes. Engineering here is not the prompt; it is a replayable record of tools, files, and outcomes. If you cannot answer which call wrote package.json, you are not operating a loop you can defend. A migration that keeps the chat and drops the tool timeline only relocates the same guessing.

What actually walks out the door

Paid coding agents usually hide three things you will miss on the second day after canceling. They hide the ordered tool timeline, the raw arguments, and the truncated results that still prove a side effect. They also hide retries that looked like one click inside the product timeline you cannot export. Your leftover is not a nicer theme; it is a missing forensic trail that used to live in their UI.

You should treat that trail as a migration artifact, the same way you treat lockfiles during a runtime move. Chat transcripts without tool envelopes cannot reconstruct a patch, because prose never names the exact side effect. Screenshots of the vendor timeline cannot feed a regression test, and they rot as soon as the seat disappears. Export what the loop did into a local ledger, then change vendors only after that file exists.

The artifact: an append-only JSONL ledger

Keep one line per tool event, and refuse pretty-printed multi-line JSON inside the same file. A line is a fact; a paragraph is a story you will fight during grep. Store the ledger beside the workspace, not inside the vendor cloud, and rotate it per branch.

Treat the following schema as a proposal until you have run it against a real failing command.

{"v":1,"ts":"2026-09-18T12:00:00Z","run_id":"cutover-014","seq":3,"actor":"agent","tool":"apply_patch","status":"ok","args_sha256":"ab12","result_sha256":"cd34","cwd":"/work/app","files":["src/auth.ts"],"exit_code":0,"duration_ms":842,"error":null}
Enter fullscreen mode Exit fullscreen mode

You hash arguments and results so the log stays small when payloads include whole files. You keep files and cwd in clear text because that is what incident review actually queries. You keep run_id plus seq so retries and hidden follow-up calls do not look like independent work. You keep actor in every line so a human override cannot impersonate the model during blame.

1. Write the record at the tool boundary

Wrap every tool invocation in one function, and deny ad-hoc subprocess calls from prompt glue. If a helper can run a command, it must emit a ledger line even when the command fails. Failures are the events you will need first, so an exception path that skips the log is a defect.

# ledger.py — proposed wrapper, not a production SDK
import hashlib, json, os, time
from datetime import datetime, timezone
from pathlib import Path

LEDGER = Path(os.environ.get("AGENT_LEDGER", ".agent/tool-calls.jsonl"))

def _sha(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()

def record(run_id, seq, tool, args, result, status, files, cwd, exit_code, started):
    LEDGER.parent.mkdir(parents=True, exist_ok=True)
    row = {
        "v": 1,
        "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "run_id": run_id,
        "seq": seq,
        "actor": "agent",
        "tool": tool,
        "status": status,
        "args_sha256": _sha(json.dumps(args, sort_keys=True).encode()),
        "result_sha256": _sha(result if isinstance(result, bytes) else str(result).encode()),
        "cwd": cwd,
        "files": files,
        "exit_code": exit_code,
        "duration_ms": int((time.time() - started) * 1000),
        "error": None if status == "ok" else str(result)[:500],
    }
    with LEDGER.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(row, separators=(",", ":")) + "\n")
    return row
Enter fullscreen mode Exit fullscreen mode

2. Validate the log before you trust a cutover

A ledger that cannot fail a test will rot into comments nobody trusts during an incident. Run a structural check in CI on every agent branch, and block merge when a required field disappears. The test below is small on purpose, because a heavy fixture will not run on every throwaway agent branch.

# test_ledger.py
import json
from pathlib import Path

REQUIRED = {
    "v", "ts", "run_id", "seq", "actor", "tool", "status",
    "args_sha256", "result_sha256", "cwd", "files", "exit_code",
    "duration_ms", "error",
}

def test_ledger_lines_are_complete():
    path = Path(".agent/tool-calls.jsonl")
    assert path.exists(), "run the loop once so the ledger has a line"
    seen = set()
    for raw in path.read_text(encoding="utf-8").splitlines():
        row = json.loads(raw)
        missing = REQUIRED - row.keys()
        assert not missing, missing
        key = (row["run_id"], row["seq"])
        assert key not in seen, key
        seen.add(key)
        assert row["v"] == 1
        assert row["status"] in {"ok", "error", "denied"}
        assert isinstance(row["files"], list)
Enter fullscreen mode Exit fullscreen mode

3. Redact before you hash, not after

Paid products often redact in the UI while still shipping secrets into their own telemetry. Your local log can be worse if you hash a token that also landed in files. Scan arguments for env-shaped strings, replace those values with names, and only then compute the hash. Never log the entire environment wholesale for debug during the first weekend on a free server.

import re

SECRETISH = re.compile(r"(?i)(api[_-]?key|token|secret|password|bearer)\s*[:=]\s*\S+")

def redact(payload: str) -> str:
    return SECRETISH.sub(r"\1=***", payload)
Enter fullscreen mode Exit fullscreen mode

Cutover plan in numbered steps

Follow this order so you do not discover a missing field after the paid seat expires.

  1. Open three recent paid sessions, list every visible tool name, and write those names into tools.txt before canceling.
  2. Map each vendor tool name to a local function, including deny paths for shells you will not allow.
  3. Record one golden run on the paid side and keep the vendor timeline only as a checksum against JSONL.
  4. Replay the same task on the new loop and compare tool, files, and exit_code sequences, ignoring chat prose.
  5. Fail the cutover if seq gaps appear, because gaps mean retries or hidden tools you have not wrapped.
  6. Freeze the schema at v1, and delay additive fields until the first week of queries has settled.

Use the following commands during step four so the ledger is real before you argue about model quality.

mkdir -p .agent
export AGENT_LEDGER="$PWD/.agent/tool-calls.jsonl"
python -m pytest test_ledger.py -q
python - <<'PY'
import json
from pathlib import Path
from collections import Counter
rows = [json.loads(l) for l in Path(".agent/tool-calls.jsonl").read_text().splitlines()]
print(Counter(r["tool"] for r in rows))
print("runs", sorted({r["run_id"] for r in rows}))
PY
Enter fullscreen mode Exit fullscreen mode

Where a free model path actually helps

After the ledger exists, swapping the completion backend is a configuration change rather than a memory loss event. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option for running the same loop while you compare tool sequences. Point the same wrapper at that path only after step four passes on a disposable branch.

You are proving that apply_patch still emits files and that denied commands still land as status=denied. If the server is free, you still meter wall time in duration_ms, because free does not mean unbounded patience from your process table. Skip slogans here, and do not treat a green chat as evidence that the cutover is finished. If the ledger diverges, keep the paid seat one more week and fix the wrapper before you cancel.

Decision table: what belongs in the line

Use this table to keep the line factual when someone asks to log everything for safety.

Event Log it? Why
Tool start with hashed args Yes Reconstruct order
Tool result hash plus exit code Yes Prove side effects
Full file contents No Blow up disk and leak secrets
Model prose in a separate file Optional Mixes story with facts
Human approval or edit Yes, actor=human Stop false blame
Raw environment block No Credential spill
Dependency install command Yes Lockfile incidents hide here

Leftovers you should expect anyway

Even a clean JSONL file will not restore vendor-only features you were quietly depending on. You will still lack their hosted session search, their shared team timeline, and whatever policy pack lived behind the login. You will also lack their silent retries unless your wrapper records them as new seq values. Write those gaps on a leftover card so nobody calls the migration feature complete after one green test.

Context windows, ranking, and editor plugins are separate migrations and should not ride along in this schema. Do not fold them into the audit trail or the schema will become a junk drawer. Finish the evidence trail first, then migrate prompts and routing as their own cutovers.

Limitations and who should not use this

This approach assumes you can intercept tools at the boundary where commands actually run. If the paid product only exposes a chat box, you cannot honestly claim parity, and you should not delete the seat. This approach also stores hashes and file paths that may remain sensitive, so legal review comes before a shared .agent directory.

Do not treat this ledger as a security information event management system with real retention guarantees. It has no retention job, no access control, and no tamper-evident chain beyond whatever git already gives you. Do not use it to justify skipping tests; a successful exit_code is not a product specification. Do not point any free server at production credentials while you compare traces on a live workspace.

Teams that never grant tools to a model can skip this entire diary without losing an operational record. People who need guaranteed uptime, signed vendor BAAs, or air-gapped legal holds need a different cutover, with procurement in the room. A local JSONL file will not satisfy those constraints, and pretending otherwise wastes a week.

Close the loop

If the next incident cannot name the tool, the file, and the run_id, the migration is not done. Keep the paid timeline until your test reads a real ledger line from a failed command, not only from a happy path. When that failure is boring to replay from JSONL alone, you can leave the paid seat.

After the schema is frozen, you can exercise the same wrapper on MonkeyCode's free model access and free server option using a throwaway branch.

Top comments (0)