# What an agent runtime has to write down before it says yes

Author: Chris Chabot
Canonical URL: https://chabot.dev/blog/what-an-agent-runtime-writes-down-before-it-says-yes/
Published: 2026-11-03

What a durable agent runtime must store before acknowledging a turn, how it recovers a crash mid tool call, and what at-least-once still leaves to the tool.

An agent turn is a poor fit for the request and response that usually carry it. The client sends one message, and behind that message sit several model calls, a handful of tool calls with effects in other systems, perhaps a compaction, and minutes of elapsed time, all inside a process that can be redeployed or killed at any point along the way. The client, meanwhile, has been told its message was accepted and has stopped holding on to it. If that acceptance only ever existed in memory, a crash leaves behind a conversation waiting for an answer nobody is working on.

So the first design question for a durable runtime is a narrow one: what has to be in storage at the moment it says yes? That question shaped the native agent SDK I built in Rust on Ferrite, my own application platform, in August 2026, and its answer decides most of what the rest of the system can honestly promise.

I should say first what isn’t mine. The shape of the SDK, an agent as a function with hooks, comes from [Flue](https://flueframework.com), a TypeScript agent framework, and the loop, sessions, compaction and recovery come from [pi](https://github.com/earendil-works/pi), which Flue is built on. I read both directly and adapted them to Ferrite. Importing them was not an option on a platform where an application may carry exactly one dependency, has no shell, SQLite or Node, and reaches storage only through handles injected into it.

## The acknowledgement

A submission begins with a hash of what was delivered and the result schema it asked for, followed by a lookup of any existing record under the submission’s id. A record with a different hash means the id has been reused for a different request, and the runtime refuses it as a conflict. A record that has already settled returns its settled outcome, so a client that lost the first response and tries again receives the original answer instead of a second run. Otherwise the runtime stores a pending record with the id, the hash, the attempt number and the creation time, and then writes a started record carrying the attempt, the delivery itself and the result schema. The comment beside that write says what it is for: “Durable start: the delivery is re-runnable from here.”

The delivery is the part that matters most. A pending flag on its own only tells a restarted process that something was lost. To do anything about it the process needs the original request, because the client has moved on and nobody is going to send it again. Identity matters for the opposite case, where the client does send it again: an idempotency key supplied at the transport becomes the submission id, so a retried HTTP request lands on the same record, and the A2A interface uses its task id in the same way. So the minimum is an identity, a hash that keeps the identity honest, and enough of the request to run it a second time.

## The transcript

The transcript gets the same discipline one level down. The storage interface the SDK asks for is small: point reads, multi-gets of up to 256 keys, and an atomic commit of up to 64 entries guarded on the value of one key. A session is a head key, a numbered run of message keys and a metadata key, and every append is one commit guarded on the head, so a second writer gets a conflict and not an interleaved history. A fork too long for one commit claims the head with a staging sentinel, writes its pages and publishes the head last, which means nobody ever reads half a fork.

The loop persists each message as it ends, and a storage failure aborts the turn. I made that a hard stop because a loop that carries on after a failed write is producing work a restart can’t see, and a transcript with a hole in it is worse than one that ends early. Model errors are handled differently, because an error the loop can retry away doesn’t belong in the history. An error is held back from the transcript until the loop knows what kind it is: transient failures are retried three times, starting at two seconds and doubling, a context overflow gets one compaction, and only a permanent failure is appended for the conversation to show.

## The crash in the middle of a tool call

The awkward case is a process that dies after recording a tool call and before recording its result. The transcript now ends on a question nobody answered, and since model APIs generally insist that every tool call be matched by a result, the conversation can’t simply continue. Recovery writes a synthetic error result for each dangling call and finishes the open operation as aborted, with `interrupted` as the reason, a classification that also comes from Flue. An error is the right thing to put there because the runtime doesn’t know whether the tool ran. Take a dangling `write` call, the case the recovery test builds by hand: the file may or may not have changed, and a runtime that guessed either way would mislead the model. Reporting the interruption leaves the question open and leaves the model free to go and look.

The pending records are then folded into what the code’s documentation calls the “at-least-once re-run queue a restart hands the app.” At start-up the app re-runs pending submissions across all its agents and instances, raising the attempt number each time, and after ten attempts by default the submission fails with a retry-exhausted error instead of looping forever.

“At least once” is the honest phrase, and it marks the limit of the whole design. Tools that need durable steps get a helper documented as “Exactly-once-recorded, at-least-once-executed,” with deterministic step names and a refusal to reuse one. A crash between an effect and the record of that effect means the effect runs again, so a provider call can be repeated, and a tool that sends an email or opens a pull request needs idempotency at the far end. What the runtime offers is narrower than exactly-once: an accepted turn is not forgotten, an interrupted one says so in its transcript, and a repeated request can’t quietly become a different one.

## One token, one identity

Because all of this sits below the protocol layer, the interfaces on top can stay thin. A JSON thread API and the implemented subsets of MCP, ACP and A2A are transport-free functions from request to response, with MCP and A2A calls bottoming out in the same submit path. Authentication is a single client token that fails closed, with a 503 when none is configured and a 401 when the wrong one is presented, while streams take a signed token bound to a subject and good for ten minutes.

A single token also means a single identity, and that is where this design runs out. Everything behind the token belongs to the deployment, and a runtime that has to hold projects, attachments and browser sessions for individual people needs an answer it doesn’t have yet.

## When the runtime has to own things for people

A multi-user console prototype I was developing in August made that ownership explicit. Identity changes first. The runtime resolves an authenticated principal, and the code is explicit about the boundary: “a `user_id` field a client sends is data, not identity.” An owner tag is built from the realm and subject, with slashes refused so one user’s tag can’t be dressed up as another’s. Projects live under their owner’s key prefix, and the stored tag is checked again on read, which the comment calls “Defence in depth,” so a mistake in key construction doesn’t hand over somebody else’s project by itself. A foreign project returns a 404 identical to a missing one, so the API can’t be used to find out what exists. Per-owner agent configuration is applied as an override at render time and leaves service instances as they were.

Attachments become durable records moving through pending, ready and deleting. The decoded storage cap is 192 KiB, but an upload must be smaller: base64 alone expands 192 KiB to the edge’s entire 256 KiB request limit, leaving no room for the JSON framing. A storage limit and an upload limit are different constraints even when they describe the same file.

Reconnection is the third change. In the original runtime, the events endpoint returns transcript messages and no deltas, so a browser that reloads in the middle of a turn can catch up on finished messages and loses whatever was streaming. The prototype adds an event journal: each instance numbers its events monotonically, keeps the most recent 20,000, and lets a client resume from a cursor, with the first retained position telling it how far back it can go. The properties that matter are the ones its test pins down: replayed sequence numbers equal the live ones, a resume from the middle works, a second turn continues the numbering, and a different user presenting a cursor gets a 404.

The journal isn’t durable the way the transcript is. Its writes are asynchronous with a flush, and a failed write “loses replay for this event only.” That inverts the transcript’s rule, where a failed write stops the turn, and I think the inversion is right: a lost journal event costs a reconnecting browser a gap in its replay, while a lost transcript message costs the conversation part of its history.
