js/ai/session
js/ai/session.ts
fino:ai/session — durable agent runs with session-owned history persistence.
Session runs an already-configured Agent against an AtomicStore.
Use it when an agent run must survive process restarts, pause for external
input, continue a thread across multiple requests, or fork from an existing
conversation state.
Storage model
MessageHistory is an immutable in-memory graph. The exported conversation
and run functions are thin record codecs over fino:store; ACP and ordinary
agent conversations use the same namespace and layout. Atomic commits store
new history graph nodes, the current run state, and the thread head together.
Session.start() creates a new run in the session thread, resume() injects
external input into a suspended run, and fork() creates a new thread from
the current history revision. Session.resume() and Session.resumeSuspended()
are for stateless adapters that need to continue from persisted state.
Suspension and approval
A run pauses in two ways: code inside a step throws SuspendSignal (waiting
for arbitrary external input), or a tool marked requiresApproval produces a
tool-approval request. Both persist a single-use resume token in
RunState.suspendedOn. Plain suspensions continue through
resume(token, value); approval suspensions continue through
approveTool() / rejectTool(), or their static *Suspended counterparts
after a process restart.
Every step is committed through the store's atomic capability before the
next one begins, so a crash never loses more than the in-flight step. The
live run state is observable through watch() for reactive consumers. An
optional AgentMemoryController feeds cross-session semantic hits into new
runs without storing conversation transcripts.
import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { sqliteStore } from 'fino:store';
import { session } from 'fino:ai/session';
const store = await sqliteStore({ path: './runs.db' });
const sess = session({
store,
agent: agent({ model: openai({ model: 'gpt-4o' }) }),
threadId: 'customer-123',
});
const first = await sess.start('Start a support conversation.');
if (first.status === 'suspended') {
await sess.resume(first.state.suspendedOn!.token, 'human input');
}Types
type RunStatus = 'running' | 'suspended' | 'done' | 'error' | 'cancelled'
Durable lifecycle state for a session run.
running is what a checkpoint says while a step is in flight — reading it
back from a store after a crash means the process died mid-drive, and
Session.resume() can re-drive from there. Driving calls only ever return
suspended or done results; failures and aborts commit an error or
cancelled checkpoint and then throw. suspended runs carry a single-use
resume token in RunState.suspendedOn.
type ToolApprovalDecision =
| { approved: true; approval?: unknown }
| { approved: false; reason?: string }
Decision supplied when resuming a tool approval suspension.
An approved decision may carry an approval value that is forwarded to the
tool's execution context. A rejection skips the tool entirely and records
reason (defaulting to 'not approved') as an error tool result visible to
the model, which then continues the conversation.
Interfaces
interface SuspendReason {
Suspension metadata persisted with a paused run.
Minted whenever a run suspends, and cleared when the run continues. For
tool-approval suspensions payload is the pending ToolApprovalRequest
(tool name, call id, arguments, risk); for SuspendSignal suspensions it is
whatever payload the signal carried.
const r = await sess.start('delete the account for acct_1');
if (r.status === 'suspended') {
console.log(r.state.suspendedOn!.reason);
notifyOperator(r.runId, r.state.suspendedOn!.token);
}Properties
token: string
Single-use resume token. Pass it to resume(), approveTool(), or
rejectTool(); it is invalidated as soon as the run continues.
reason?: string
Human-readable explanation of why the run paused.
payload?: unknown
Structured data describing what the run is waiting for.
interface RunState {
Durable checkpoint state for one run.
This is the record the session codec persists after every step. Conversation
content never lives here — historyRevisionId points into the immutable
history graph instead, which keeps checkpoints small and lets many runs
share one graph.
const state = await store.loadRun(runId);
if (state?.status === 'suspended') {
console.log(`step ${state.stepIndex}, waiting on: ${state.suspendedOn?.reason}`);
}Properties
runId: string
Unique identifier of this run.
threadId: string
Thread the run belongs to.
status: RunStatus
Current lifecycle state of the run.
stepIndex: number
Number of agent steps completed so far.
usage: Usage
Token usage accumulated across all steps of the run.
cost?: number
Accumulated cost reported by the agent, when the model prices requests.
historyRevisionId?: string
Head of the immutable history graph as of the last committed step.
suspendedOn?: SuspendReason
Suspension metadata; present only while status is 'suspended'.
scratch: Record<string, unknown>
Store-compatible structured-cloneable bag for application bookkeeping carried across checkpoints and restarts.
result?: unknown
Final assistant text once the run reaches 'done'.
error?: { message: string; stack?: string }
Failure captured when the run reached 'error'.
interface ThreadState {
Durable pointer for one conversation thread.
A thread is a named line of conversation. Its historyRevisionId is the
head of the immutable history graph and advances with every committed step;
starting a new run on the same threadId continues from this head, which is
how a conversation spans multiple runs and process lifetimes.
const thread = await loadConversationThread(store, 'customer-123');
if (thread?.historyRevisionId) {
const history = await loadConversationHistory(store, thread.historyRevisionId);
console.log(history?.render().length, 'messages so far');
}Properties
threadId: string
Unique identifier of the thread.
historyRevisionId?: string
Current head revision of the thread's history graph; absent until the first commit.
createdAt: number
Creation time in milliseconds since the epoch.
updatedAt: number
Time of the last commit in milliseconds since the epoch.
storeVersion?: string
Opaque backing-store version assigned after every successful commit. It is absent only on a thread value that has not been persisted yet.
metadata?: Record<string, unknown>
Store-compatible structured-cloneable conversation metadata owned by the caller.
The store treats this as opaque data and commits it atomically with the history head. Protocol adapters can therefore retain their own session state without introducing a parallel persistence abstraction.
interface ConversationCommitOptions {
Values atomically committed by commitConversationThread().
Properties
thread: ThreadState
New thread head and caller-owned metadata.
history: MessageHistory
Immutable history graph whose head the thread references.
expectedStoreVersion: string | null
Store version returned by loadConversationThread(), or null for creation.
baseRevisionId?: string
Existing graph head; only later history nodes are written when supplied.
interface AgentSessionCommitOptions {
Values atomically committed by commitAgentSession().
Properties
run: RunState
Run checkpoint written with the thread head.
thread: ThreadState
New thread head.
history: MessageHistory
Immutable history graph referenced by both records.
expectedThreadRevisionId: string | null
History head observed before this commit, or null for a new thread.
baseRevisionId?: string
Existing graph head; only later history nodes are written when supplied.
interface RunResult {
Result returned by session start, resume, and fork operations.
Driving calls resolve with 'suspended' (the run paused; the resume token
is in state.suspendedOn) or 'done' (the agent finished; text carries
the last assistant text). They never resolve with 'error' — failures
commit an error checkpoint and then throw. 'done' and 'cancelled' also
appear when Session.resume() is pointed at an already-terminal run.
const r = await sess.start('What changed in the last release?');
if (r.status === 'done') console.log(r.text);
else console.log('paused:', r.state.suspendedOn?.reason);Properties
runId: string
Identifier of the run; use it with the static Session.resume* helpers.
status: RunStatus
Lifecycle state the run settled in for this call.
state: RunState
The run's checkpoint as of the returned status.
text?: string
Last assistant text, present when the run completed.
interface SessionOptions {
Options for creating a Session.
import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { memoryStore } from 'fino:store';
import { session } from 'fino:ai/session';
const sess = session({
store: memoryStore(),
agent: agent({ model: openai({ model: 'gpt-4o' }) }),
threadId: 'customer-123',
onCheckpoint: (s) => console.log(s.status, 'at step', s.stepIndex),
});Properties
store: AtomicStore
Durable store that receives every checkpoint.
agent: Agent
Configured agent whose step loop the session drives.
memory?: AgentMemoryController
Optional long-term semantic memory. On start() matching hits are folded
in ahead of the input. Conversation messages are never written to memory;
use the opt-in memory creation tool or call remember() explicitly.
threadId?: string
Thread to continue. Defaults to a freshly generated id, i.e. a new conversation.
onCheckpoint?: (s: RunState) => void
Called after each committed step with the new run state.
Classes
class ConversationConflictError extends Error {
Raised when a conversation thread changes after a caller loads it.
Properties
threadId: string
Thread whose optimistic store version changed.
expectedVersion: string | null
Store version observed by the caller, or null for creation.
actualVersion: string | null
Current store version, or null when the thread does not exist.
Constructors
constructor(threadId: string, expectedVersion: string | null, actualVersion: string | null)
Create a typed conversation-store conflict.
class SessionConflictError extends Error {
Raised when a durable thread advances after a caller read its head.
The failed commit writes nothing. Callers may reload and retry, reject the overlapping turn, or explicitly fork from the revision they originally read.
Properties
threadId: string
Thread whose optimistic concurrency check failed.
expectedRevisionId: string | null
Revision the caller expected, or null for a new thread.
actualRevisionId: string | null
Revision currently stored, or null when the thread does not exist.
Constructors
constructor(threadId: string, expectedRevisionId: string | null, actualRevisionId: string | null)
Create a typed thread-head conflict.
class Session {
Durable runner for an agent thread.
A Session binds an Agent to an AtomicStore and one conversation
thread. Each start() creates a run; the session drives the agent one step
at a time, committing a checkpoint after every step, and returns when the
run completes or suspends. A single driving call may take at most 100 steps
before failing with a step-count error.
Instances hold the latest RunState in memory (state, watch()), but a
run does not depend on its instance surviving: the static resume(),
resumeSuspended(), approveSuspended(), and rejectSuspended() helpers
rebuild everything from the store, which is what stateless adapters such as
HTTP handlers should use.
Construct sessions with the session() factory — the constructor is
private.
import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { sqliteStore } from 'fino:store';
import { session } from 'fino:ai/session';
const store = await sqliteStore({ path: './runs.db' });
const sess = session({
store,
agent: agent({ model: openai({ model: 'gpt-4o' }) }),
threadId: 'ticket-42',
});
const r = await sess.start('Summarize the open issue.');
if (r.status === 'suspended') {
const token = r.state.suspendedOn!.token;
const finished = await sess.resume(token, 'here is the missing detail');
console.log(finished.text);
} else {
console.log(r.text);
}Getters
get state(): RunState | undefined
Latest run state held by this instance, or undefined before a run has
been started or loaded.
Methods
watch(): ReadonlySignal<RunState | undefined>
Watch the latest run state held by this session instance.
The signal is undefined until a run is started or loaded. Once a run
exists, it retains the latest checkpoint or terminal state for reactive UI
consumers in this realm.
sess.watch().subscribe((state) => {
if (state) render(`${state.status} — step ${state.stepIndex}`);
});
await sess.start('go');async start(
input: string | { messages: ModelMessage[] },
opts?: { runId?: string; signal?: AbortSignal },
): Promise<RunResult>
Start a new run in this session thread.
Input is a plain user string or pre-built messages. If the thread already
has committed history, the new run continues from its head — this is how
a conversation carries across runs, restarts, and requests. When the
session has an AgentMemoryController, semantic hits are folded in ahead
of the input and the selection id is retained in state.scratch for a
later eval outcome. Conversation history remains entirely separate.
Resolves with a 'done' or 'suspended' result. Agent errors, aborts
via opts.signal, and exceeding the 100-step budget commit the matching
error/cancelled checkpoint and then throw.
const r = await sess.start('What did we decide about the rollout?');
console.log(r.status === 'done' ? r.text : r.state.suspendedOn?.reason);async resume(
resumeToken: string,
value: unknown,
opts?: { signal?: AbortSignal },
): Promise<RunResult>
Resume the current suspended run with external input.
value is injected as a user message (strings verbatim, anything else
JSON-stringified) and the agent loop continues from the persisted history
revision. Throws if no run has started, the run is not suspended, the
token does not match (tokens are single-use), or the suspension is a
tool approval — those must go through approveTool() / rejectTool().
const r = await sess.start('book the trip');
if (r.status === 'suspended') {
const done = await sess.resume(r.state.suspendedOn!.token, {
departure: '2026-08-01',
});
}approveTool(
resumeToken: string,
opts: { approval?: unknown; signal?: AbortSignal } = {},
): Promise<RunResult>
Approve a pending approval-required tool call and continue the run.
The tool executes exactly once, with opts.approval forwarded to its
execution context, then the agent loop continues from the resulting tool
message. Throws if no run has started, the run is not suspended, the
token does not match, or the suspension is not a tool approval (use
resume() for those).
const r = await sess.start('delete acct_1');
if (r.status === 'suspended') {
const done = await sess.approveTool(r.state.suspendedOn!.token, {
approval: { approvedBy: 'ops@example.com' },
});
}rejectTool(
resumeToken: string,
reason?: string,
opts: { signal?: AbortSignal } = {},
): Promise<RunResult>
Reject a pending approval-required tool call and continue the run with an error tool result visible to the model.
The tool never executes. reason (default 'not approved') becomes the
error tool result, so the model can explain or try another approach.
Throws under the same conditions as approveTool().
suspend(opts?: { reason?: string; payload?: unknown }): never
Suspend execution from inside workflow or application code.
Throws SuspendSignal and never returns. Call it from code running
within a driven step (for example a tool body) to pause the run: the
session checkpoints a suspended state with a fresh single-use resume
token, and reason/payload surface on RunState.suspendedOn.
async fork(
input: string | { messages: ModelMessage[] },
opts?: { runId?: string; signal?: AbortSignal },
): Promise<RunResult>
Fork the current history into a new thread and start a new run.
The fork shares the committed history up to the current revision and then
diverges: it gets a freshly generated threadId and its own run state,
and subsequent steps in either thread never affect the other. Because the
history graph is immutable and shared, the fork costs only new revisions,
not a copy of the conversation. Throws if no run has started or the
current run has no committed history revision.
await sess.start('Draft the launch plan.');
const alt = await sess.fork('Now redo it assuming a two-week delay.');
console.log(alt.state.threadId !== sess.state!.threadId); // trueasync cancel(): Promise<void>
Mark the current run as cancelled.
Commits a cancelled checkpoint for the current run. This does not
interrupt an in-flight start()/resume() — pass an AbortSignal to
those calls to stop work in progress. No-op when no run has started.
Static Methods
static async resume(opts: SessionOptions & { runId: string }): Promise<RunResult>
Resume a non-suspended run from its persisted checkpoint.
Intended for crash recovery: a run whose last checkpoint is running
(the process died mid-drive) or error is re-driven from its committed
history revision. Already-terminal runs (done, cancelled) return
their stored result without invoking the agent.
Throws if the run id is unknown, or if the run is suspended —
suspended runs need their resume token, via the instance resume() or
Session.resumeSuspended().
import { Session } from 'fino:ai/session';
const result = await Session.resume({ store, agent: bot, runId });
console.log(result.status, result.text);static async resumeSuspended(
opts: SessionOptions & {
runId: string;
resumeToken: string;
value: unknown;
signal?: AbortSignal;
},
): Promise<RunResult>
Resume a suspended run from durable state.
Use this from stateless adapters such as HTTP channels where the original
Session instance may no longer be in memory. The token is checked against
the stored run and remains single-use. Throws if the run id is unknown,
the run is not suspended, the token does not match, or the suspension is
a tool approval — those go through approveSuspended() /
rejectSuspended() instead.
import { Session } from 'fino:ai/session';
app.post('/runs/:runId/resume', async (req) => {
const { token, value } = await req.json();
return Session.resumeSuspended({
store, agent: bot,
runId: req.params.runId,
resumeToken: token,
value,
});
});static async approveSuspended(
opts: SessionOptions & {
runId: string;
resumeToken: string;
approval?: unknown;
signal?: AbortSignal;
},
): Promise<RunResult>
Approve a persisted tool approval suspension after process restart.
Loads the run from the store and continues it as approveTool() would:
the pending tool executes (exactly once), opts.approval is forwarded to
its execution context, and the agent loop runs on to completion or the
next suspension. Throws if the run id is unknown, the token does not
match, or the run is not suspended for tool approval.
import { Session } from 'fino:ai/session';
const resumed = await Session.approveSuspended({
store, agent: bot,
runId: pending.runId,
resumeToken: pending.state.suspendedOn!.token,
});static async rejectSuspended(
opts: SessionOptions & {
runId: string;
resumeToken: string;
reason?: string;
signal?: AbortSignal;
},
): Promise<RunResult>
Reject a persisted tool approval suspension after process restart.
The pending tool never executes; opts.reason (default 'not approved')
is recorded as an error tool result and the model continues from there.
Throws under the same conditions as approveSuspended(). Tokens are
single-use across both decisions: once a suspension is approved it can no
longer be rejected, and vice versa.
Functions
async function loadAgentRun(store: AtomicStore, runId: string): Promise<RunState | null>
Load a detached agent run checkpoint, or null.
async function listAgentRuns(
store: AtomicStore,
filter: { threadId?: string } = {},
): Promise<RunState[]>
List detached agent run checkpoints, optionally restricted to one thread.
async function deleteAgentRun(store: AtomicStore, runId: string): Promise<void>
Delete a run checkpoint while retaining its thread and shared history.
async function loadConversationThread(
store: AtomicStore,
threadId: string,
): Promise<ThreadState | null>
Load a detached conversation thread with its current store version.
async function listConversationThreads(store: AtomicStore): Promise<ThreadState[]>
List detached conversation threads newest first.
async function deleteConversationThread(
store: AtomicStore,
threadId: string,
): Promise<boolean>
Delete one thread head while retaining runs and shared history nodes.
async function loadConversationHistory(
store: AtomicStore,
revisionId: string,
): Promise<MessageHistory | null>
Reconstruct an immutable conversation history from one graph revision.
async function commitConversationThread(
store: AtomicStore,
args: ConversationCommitOptions,
): Promise<ThreadState>
Atomically commit a conversation history delta, thread head, and metadata.
async function commitAgentSession(
store: AtomicStore,
args: AgentSessionCommitOptions,
): Promise<void>
Atomically commit a run checkpoint with its history and thread head.
function session(opts: SessionOptions): Session
Create a durable Session.
The factory for the Session class, whose constructor is private. Pass a
threadId to continue an existing conversation thread; omit it to start a
fresh one.
import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { sqliteStore } from 'fino:store';
import { session } from 'fino:ai/session';
const store = await sqliteStore({ path: './runs.db' });
const sess = session({
store,
agent: agent({ model: openai({ model: 'gpt-4o' }) }),
threadId: 'customer-123',
});
const r = await sess.start('Where did we leave off?');