Skip to content

The layers

durable — Survive Restarts

Sessions, the append-only transcript tree, and the store contract that let an agent outlive its process

Read this when
  • Persisting conversations past a process restart
  • Implementing edit, retry, fork, or compaction on a conversation
  • Deciding what a session ID should mean in your application

You can make an agent survive restarts with pkg/durable and pkg/session.

pkg/durable · sessionpkg/agent
fig. 01 — One durable run, from session ID to store

A session ID opens or resumes a conversation. Input persists before the inner loop starts, and each produced message persists as it ends. A new process opens the same ID and rebuilds the same model history.

store, err := fs.New("/var/lib/app/sessions") // or session.NewMemoryStore()
a, err := durable.New(ctx,
durable.Model(model),
durable.WithStore(store),
durable.WithSessionID("ticket-8472"), // create it, or resume it
agent.WithTools(lookupOrder), // agent options ride along
// ...
)
msgs, err := a.Run(ctx, durable.Text("Where is order order_1234?")).Wait()

The store keeps the transcript, and nothing else. Titles, modes, model choices, and UI state stay in your own tables, keyed by the same session ID. A Store that holds product metadata leaks every application schema into the SDK.

The parent pointer is the tree

Every session.Entry carries an EntryHeader with ID, ParentID, and CreatedAt. The store stays a flat append-only log. The pointers make it a tree, and the agent tracks one leaf.

One session after an edited question
e1 ─── e2 ─── e3 ─── e4 the first answer, now abandoned
└──── e5 ─── e6 ← leaf Branch(ctx, e2), then Run again
a.LeafID() // where the next entry attaches
a.Branch(ctx, "e2") // edit, retry, and rewind are all this
forked, err := a.Fork(ctx, "ticket-8472-what-if") // copy the active path
a.Compact(ctx, durable.KeepTurns(4)) // summarize, remove nothing

Compact appends a session.CompactionEntry and changes projection only. You can still branch to an entry from before the compaction, and that branch returns the uncompacted path.

One turn, four audiences

A durable run takes entries, not messages. That boundary lets one turn carry values for readers who must not see the same things.

EntryModel readsStore keepsTranscript shows
durable.Text and friendsyesyesyes
durable.Meta(…)yesyesno
durable.Ephemeral(…)yesnono
session.CustomEntrynoyesyes
// A live reminder: the model reads it once, the store never sees it.
a.Run(ctx,
durable.Text("Rebase this branch."),
durable.Ephemeral(durable.Text("The build is red since 14:02 UTC.")),
)
// An application record the model must never read.
a.Append(ctx, ArtifactEntry{
CustomEntry: session.CustomEntry{Kind: "artifact"},
// ...
})
artifacts := session.Filter[ArtifactEntry](entries)

Lifecycle events arrive outside the run

a, err := durable.New(ctx, durable.Model(model),
durable.WithPublisher(pub), // session_init, branched, forked, compacted
)

The run stream carries the inner agent events under EventAgent. Session lifecycle changes happen between runs, so a publisher receives them instead. The publisher runs after the mutation commits.

Go deeper