The pattern

Most agent harnesses keep a turn in the memory of the process running it. When that process dies halfway through a turn, because of a timeout, an out-of-memory error or a redeploy, the turn dies with it. You have already paid for the model calls that ran, and you no longer know which side effects happened. Did the refund go through? Was the email sent?

In Making every harness durable, published on 19 October 2026, Vercel describes its fix: a durable step journal. "Before a model call or tool call runs, it records an intent, and when the call finishes, it records the outcome." Each turn is also backed by a queue message, so if the function dies, the queue hands the turn to a new function that reads the log and continues. Finished calls are read back from the log, not run again, which, Vercel says, means you are never billed twice for a finished model call.

The second half of the pattern is a label on every tool. In Vercel's tool docs, a tool marked replay: 'safe' (a lookup such as get_order) can run again if a crash interrupts it. A tool marked replay: 'never' (such as refund_order) never runs twice. Instead, the model is told the call may or may not have happened, so it can check before it tries again.

Vercel ships this as the open-source durable-harness package for Pi, OpenCode, Codex and Claude Code. Its harness docs mark Codex and Claude Code as experimental and Sandbox-only. You don't need Vercel to use the idea, though. All it takes is a table, a queue and a loop that checks the table before it acts.

When to use it and when not

Use it when:

  • A turn can run longer than your function's maximum duration, such as research jobs, multi-file edits or batch enrichment.

  • Your agent has tools with side effects: payments, refunds, sending email, posting to social, writing to a CRM.

  • Model calls are expensive enough that re-running a whole turn after a crash costs you real money.

  • Users close the tab and expect the job to finish anyway.

Skip it when:

  • Each turn is one short model call with no tools. A retry costs pennies and repeats nothing.

  • Every tool is read-only. Re-running the whole turn is simpler than journalling it.

  • You already run on a durable workflow engine. Use its step primitive and don't build a second one on top.

  • It's a script on your laptop that you watch while it runs.

How it works (input → step → output)

Input. A queue message {session_id, turn_id} and the conversation so far.

Step. The worker claims the message with a lease and loads every journal row for that turn. Then it walks the agent loop, numbering each call in order (step 0, 1, 2 and so on). For each step:

  1. Outcome already recorded: return the stored result and don't call anything.

  2. Intent recorded but no outcome: the call was interrupted. A model call runs again. A safe tool runs again. A never tool does not run. The loop hands the model a tool result saying the call may or may not have happened.

  3. Nothing recorded: write the intent, run the call, then write the outcome.

Output. The final reply, a complete journal for the turn and an acknowledged queue message.

Step 2 is the case the labels exist for. Vercel's recovery docs note that "a call that finished just before the crash may not be in the log yet, and then it counts as running." A refund can succeed and the worker can die before writing the outcome. From the journal, that looks the same as a refund that never started. The never label stops the code from guessing, and lets the model look before it acts again.

Replay works because each model response is read back from the journal. The conversation the model sees on the second worker is byte-for-byte what it saw on the first, so it makes the same next decision.

Components

  1. Journal table in any database: Postgres, SQLite, Redis or Mongo. Vercel's own self-hosted options are Postgres, MongoDB, Redis or Turso.

  2. Queue with leases: SQS visibility timeouts, BullMQ, Cloudflare Queues, or a Postgres jobs table with SELECT … FOR UPDATE SKIP LOCKED. If a worker vanishes without a trace, the lease runs out and the turn is delivered again.

  3. Tool registry where every tool declares replay: safe | never. Make the label a required field, with no default.

  4. Replay-aware runner, a wrapper around every model and tool call.

  5. A unique constraint on (turn_id, step) so two workers can't both claim the same step.

Minimal implementation (pseudo-code)

-- journal
CREATE TABLE journal (
  turn_id  text, step int, kind text,      -- 'model' | 'tool'
  name text, input jsonb, replay text,     -- 'safe' | 'never'
  status text,                             -- 'intent' | 'done'
  output jsonb, created_at timestamptz,
  PRIMARY KEY (turn_id, step)
);

TOOLS = {
  get_order:    {replay: "safe",  run: get_order},
  refund_order: {replay: "never", run: refund_order},
}

POSSIBLY_RAN = "This call was interrupted. It may or may not have " +
               "happened. Check the current state before retrying."

def journaled(turn, step, kind, name, input, run, replay):
    row = db.get(turn, step)
    if row and row.status == "done":
        return row.output                       # read back, no cost
    if row and row.status == "intent":
        if kind == "tool" and replay == "never":
            db.finish(turn, step, POSSIBLY_RAN)
            return POSSIBLY_RAN                 # model decides
        # model call or safe tool: fall through and re-run
    else:
        db.insert_intent(turn, step, kind, name, input, replay)  # commit first
    out = run(input)
    db.finish(turn, step, out)                  # status = 'done'
    return out

def handle(msg):                                # queue consumer
    lease = queue.claim(msg, renew_every=30s)
    convo, step = load_conversation(msg.turn_id), 0
    while True:
        reply = journaled(msg.turn_id, step, "model", "llm", convo,
                          call_model, "safe"); step += 1
        if not reply.tool_calls: break
        for tc in reply.tool_calls:
            t = TOOLS[tc.name]
            res = journaled(msg.turn_id, step, "tool", tc.name, tc.args,
                            t.run, t.replay); step += 1
            convo.append(tc, res)
    save_reply(msg.turn_id, reply); queue.ack(lease)

Two details matter. First, the intent row must be committed before a never tool runs, not batched with the outcome. Second, nothing that changes between runs (timestamps, random IDs) should go into the prompt unless it came from the journal.

Failure modes and evals

A tool wrongly marked safe. Say send_invoice is labelled safe because it "just calls an API". The email provider accepts the send, and the worker is killed before the outcome is written. On replay the intent has no outcome, the label says safe, and the customer gets a second invoice. Eval: for every tool, inject a crash after run() returns but before finish() is called. Replay, then count the external side effects, using a test inbox or a sandbox payment account. Any count above one means the tool gets relabelled never, or it gets an idempotency key that the downstream API honours.

The model ignores the "may have happened" note. Eval: a scripted scenario where refund_order is interrupted. Pass only if the model calls get_order (or asks the user) before it calls the refund again. Run it 20 times and track the pass rate. This pairs well with a read-back verification loop.

Replay diverges. Something outside the journal changed the prompt, so step 3 on the second worker is a different call from step 3 on the first. Eval: replay a finished turn with the model client switched off. It must produce the same reply with zero model calls.

Two workers on one turn. A long tool call outlives the lease. Eval: set the lease to 5 seconds, make a tool sleep for 10, and check that the primary-key constraint rejects the second worker's intent insert.

Journal bloat. Large tool outputs, such as scraped pages, inflate every replay. Store them as blobs and keep a hash in the row.

If you've built a thin orchestrator or put required steps in code, the journal wrapper slots in at the same place: the one function every call already goes through.

Build-it-this-weekend exercise

  1. Create the journal table in SQLite and a jobs table to use as your queue.

  2. Give a small agent two tools: lookup_note (safe) and send_email (never), pointed at a test inbox you control.

  3. Add a CRASH_AT environment variable, for example CRASH_AT=4:after_run, that calls os._exit(1) at that exact point.

  4. Run a turn that uses both tools. Crash it at every step, once before run() and once after, and restart a worker each time.

  5. Record three numbers per crash point: model calls on replay, emails received, and whether the final reply matched a clean run.

Target: zero model calls repeated for finished steps, exactly one email at every crash point, and the same final reply. Then deliberately relabel send_email as safe and watch the duplicate arrive. That duplicate is the failure mode this whole pattern exists to stop.

Comments

Avatar

or to participate