Concepts · durable
Sessions
What the session record is, what the store owns, and where application metadata lives
- Deciding what a session ID should mean in your application
- Implementing a Store over your own database
- Understanding what survives a crash and what is repaired on resume
- Deciding which session changes publish lifecycle events
This page gives you the session boundary, store contract, metadata boundary, and event rules for durable conversations.
A durable session contains two persisted parts: a Session record that marks existence and lineage, and an append-only entry log. pkg/session defines both. pkg/durable runs the conversation from the entry log.
The session ID is the memory boundary
The SDK never assigns meaning to a session ID. The application chooses the string. That choice is the main design decision. A user ID gives one continuous thread per person. A ticket number gives one thread per problem. A random ID gives a throwaway conversation.
Opening an agent with an ID that exists resumes it. Opening one with an unknown ID creates it. New makes that decision by calling LoadEntries: ErrSessionNotFound leads to CreateSession, and existing entries hydrate the transcript tree. There is no separate resume verb because both paths produce the same ready agent.
da, err := durable.New(ctx, durable.Model(lm), durable.WithStore(store), durable.WithSessionID("user-42"),)The first argument is a factory, not a model
New takes a Factory, which builds the inner loop for one run. durable.Model wraps an ai.LanguageModel for the ordinary case. A subprocess CLI supplies its own factory. Durability does not know which loop it wraps.
A nil factory is valid. The agent records entries but never runs: Append, Branch, and the read verbs work. Run reports the missing loop. A caller that repairs an interrupted transcript needs exactly that behavior.
Instances are not tracked. Two live agents on the same session each append from the leaf they loaded. Concurrent instances grow sibling branches instead of overwriting history.
The agent owns transcript state, not application state
The durable agent keeps a session ID, the loaded entries, a tree index, and an in-memory leaf. Call SessionID when New generated the ID for you.
| Concern | Owner | Mutation rule |
|---|---|---|
| Session existence and fork lineage | Store, written by CreateSession | Written once, never updated |
| Application metadata (title, mode, model) | Application’s own storage, keyed by session ID | Never passes through the SDK |
| Transcript entries | Durable agent through Store | Append only |
| Active leaf | One live durable agent | Move in memory with Branch |
| Run progress and persistence receipts | The stream returned by Run | Scoped to one run |
| Lifecycle notifications | Durable agent through Publisher | Publish after commit |
Application metadata stays in the application
The session record carries no application state: no title, no mode, and no model choice. The application keeps conversation metadata in its own storage, keyed by the session ID. That storage uses the schema and write policy the application wants.
Design: an earlier revision made Session[T] generic over an application state type. LoadSession and UpdateSession were part of the store contract. The durable agent never read that state. It creates the record and runs entirely from the entry log. The type parameter spread to every SDK signature: Store[T], Agent[T], New[T], and WithStore[T]. It also forced a runtime type assertion where options met construction.
Interfaces belong to their consumers. Store now contains exactly the three operations the durable agent performs. Application metadata does not need an SDK-imposed shape. The concrete stores still expose LoadSession for reading back existence and lineage. That method is not part of the contract that a custom store must implement.
A useful result of the boundary: transcript activity and application metadata cannot affect each other behind the SDK. Appending entries never touches metadata. A metadata write in application storage cannot alter parent chains, branches, or model context. If an application wants an auditable metadata change in the conversation itself, it can append its own CustomEntry explicitly.
The store preserves the ownership boundary
A Store has three methods: CreateSession, LoadEntries, and AppendEntries. CreateSession registers existence and lineage. LoadEntries and AppendEntries manage the append-only transcript log. The application owns the schema, encoding, and database behind that boundary.
The memory store keeps records and logs in separate maps. The filesystem store uses one append-only JSONL file per session. The first line is a session_init event that carries the record:
<root>/<session-id>.jsonl {"type":"session_init","id":...,"parent_id":...,"created_at":...} {"type":"message",...} ...CreateSession writes the session_init line. Everything after it is appended. Nothing is ever rewritten, and the file only grows. Every line carries a type discriminator. The log reads as one uniform event stream with session creation in-band as its first event. Entries return in append order because the tree comes from parent pointers. The leaf returns as the last appended entry.
Custom entry types need session.RegisterCustom once at init, so a store can decode them back into concrete Go types. Unregistered kinds decode to a bare CustomEntry. The header and kind survive, but the application fields do not.
Fork creates transcript identity
Fork creates a child session with a new ID and the source session ID as its ParentID. It then copies and re-chains only the source agent’s active entry path. If the child must inherit application metadata, the application copies that metadata in its own storage.
Run events are persistence receipts
The durable agent persists run input before the loop starts. When message_end arrives, it persists each message that the loop produces. It forwards the event only after that append succeeds. The forwarded event carries the entries it wrote.
That ordering makes the run stream trustworthy. An event a consumer saw already has its data in the store. A UI that showed a message never has to explain a message that vanished after a crash.
The cost is a narrow crash window with a known repair. If a crash occurs between an assistant message and its tool results, the transcript contains a tool call with no result. Providers reject that state. On resume, the agent repairs it by synthesizing interrupted tool results for orphaned calls. A session that died mid-tool reopens into a valid conversation.
Lifecycle mutations use a publisher
New, Branch, Fork, and Compact change session lifecycle state outside a run. Configure WithPublisher to receive session_init, session_branched, session_forked, and session_compacted after those effects succeed.
The publisher stays separate from the run stream because these mutations do not belong to a turn. The agent calls Publish synchronously outside its locks. A publisher can inspect the agent or hand the event to another system. Keep Publish fast because it runs on the mutating call’s path.
Forked agents inherit the source publisher. A successful fork publishes session_forked for the source and then session_init for the child. If Fork returns an error, it publishes neither event.
Application metadata changes do not publish. They never pass through the agent or its store, so the SDK cannot observe them. If metadata changes need notifications, emit them in the application service that performs them.
See Durable Events for event fields, ordering, delivery semantics, and a publisher example.
Related
- Entries — entry visibility and persistence
- Transcript Tree — the leaf pointer, branching, forking, compaction
- Durable Events — run receipts and lifecycle notifications
- Agent — the loop a durable agent wraps
- Streaming — the run stream whose events carry the receipts