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 a SessionStore.
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. A SessionStore is the
durable boundary: it commits new history graph nodes, the current run state,
and the thread head revision atomically. RunState and ThreadState store
only revision ids, never rendered message arrays or snapshot blobs.
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 SessionStore.commitSession() 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, and
an optional Memory feeds recalled context into new runs and records every
appended message.
import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { session, SqliteSessionStore } from 'fino:ai/session';
const store = await SqliteSessionStore.open('./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 a SessionStore 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>
JSON-serializable 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 store.loadThread('customer-123');
if (thread?.historyRevisionId) {
const history = await store.loadHistory(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.
interface SessionStore {
Durable session boundary for runs, threads, and immutable history graphs.
Implement this interface to back sessions with custom storage;
InMemorySessionStore and SqliteSessionStore are the built-in
implementations. The contract that matters most is commitSession(): it
must persist the history delta, the run checkpoint, and the thread head as
one atomic unit, because a partially applied commit would leave a run
pointing at a revision that does not exist. History entries and revisions
are content-immutable, so their writes may be insert-if-absent.
import { SqliteSessionStore, type SessionStore } from 'fino:ai/session';
const store: SessionStore = await SqliteSessionStore.open('./runs.db');
for (const run of await store.listRuns({ threadId: 'customer-123' })) {
console.log(run.runId, run.status);
}
Methods
loadRun(runId: string): Promise<RunState | null>
Load a persisted run checkpoint, or null if the run id is unknown.
listRuns(filter?: {
threadId?: string;
}): Promise<RunState[]>
List persisted run checkpoints, optionally scoped to one thread.
deleteRun(runId: string): Promise<void>
Remove a run checkpoint. History entries and revisions are shared with the thread and other runs, so they are left in place.
loadThread(threadId: string): Promise<ThreadState | null>
Load a thread head pointer, or null if the thread id is unknown.
loadHistory(revisionId: string): Promise<MessageHistory | null>
Reconstruct the MessageHistory graph reachable from a revision id
(the revision plus its full parent lineage), or null if the revision
is unknown.
commitSession(args: {
run: RunState;
thread: ThreadState;
history: MessageHistory;
baseRevisionId?: string;
}): Promise<void>
Atomically persist one checkpoint: the history entries and revisions
added since baseRevisionId (all of them when it is omitted), the run
checkpoint, and the thread head. Implementations should reject commits
whose run or thread revision pointers disagree with
history.revisionId, as the built-in stores do.
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 { session, InMemorySessionStore } from 'fino:ai/session';
const sess = session({
store: new InMemorySessionStore(),
agent: agent({ model: openai({ model: 'gpt-4o' }) }),
threadId: 'customer-123',
onCheckpoint: (s) => console.log(s.status, 'at step', s.stepIndex),
});
Properties
store: SessionStore
Durable store that receives every checkpoint.
agent: Agent
Configured agent whose step loop the session drives.
memory?: Memory
Optional long-term memory. On start() its recalled context (prior
messages, semantic hits, working memory) is folded in ahead of the input,
and every new message produced by the run is appended to it.
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 InMemorySessionStore implements SessionStore {
In-memory session store for tests and simple single-process agents.
Implements the full SessionStore contract with deep-cloned state, so
mutating a returned run, thread, or history never leaks back into the
store. Nothing survives the process; switch to SqliteSessionStore when
runs must outlive it.
import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { session, InMemorySessionStore } from 'fino:ai/session';
const store = new InMemorySessionStore();
const sess = session({ store, agent: agent({ model: openai({ model: 'gpt-4o' }) }) });
const r = await sess.start('hello');
console.log(await store.loadRun(r.runId));
Methods
async loadRun(runId: string): Promise<RunState | null>
Load a copy of a run checkpoint, or null if the run id is unknown.
async listRuns(filter: {
threadId?: string;
} = {}): Promise<RunState[]>
List copies of stored run checkpoints, optionally scoped to one thread.
async deleteRun(runId: string): Promise<void>
Remove a run checkpoint; history entries and threads are untouched.
async loadThread(threadId: string): Promise<ThreadState | null>
Load a copy of a thread head, or null if the thread id is unknown.
async loadHistory(revisionId: string): Promise<MessageHistory | null>
Rebuild the history graph reachable from a revision id, or null if the
revision is unknown.
async commitSession(args: {
run: RunState;
thread: ThreadState;
history: MessageHistory;
baseRevisionId?: string;
}): Promise<void>
Persist a checkpoint: history delta since baseRevisionId, run state,
and thread head. Throws if the run or thread revision pointers disagree
with the committed history's head revision.
async save(s: RunState): Promise<void>
Write a run checkpoint directly, without touching thread or history.
Lower-level convenience mirroring SqliteSessionStore.save().
async load(runId: string): Promise<RunState | null>
Alias of loadRun().
async list(filter: {
threadId?: string;
} = {}): Promise<RunState[]>
Alias of listRuns().
async delete(runId: string): Promise<void>
Alias of deleteRun().
class SqliteSessionStore implements SessionStore {
Database-backed session store.
The class name is retained for compatibility: pass a filesystem path or
sqlite:// URL for SQLite, or a postgres:// URL for Postgres — the
engine is chosen through fino:database. commitSession() runs each
checkpoint in a single transaction, so a crash mid-commit leaves the
previous checkpoint intact.
Runs and threads are stored as rows keyed by id; history entries and
revisions are stored append-only and reassembled into a MessageHistory
by walking the revision lineage.
import { SqliteSessionStore } from 'fino:ai/session';
await using store = await SqliteSessionStore.open('./runs.db');
const stuck = (await store.listRuns()).filter((r) => r.status === 'suspended');
console.log(`${stuck.length} runs waiting on input`);
Static Methods
static async open(path: string, opts?: {
fs?: object;
}): Promise<SqliteSessionStore>
Open (or create) a session database and ensure its schema exists.
path accepts anything fino:database understands: a plain file path or
sqlite:// URL (including :memory:), or a postgres:// URL. The
runs, threads, history_entries, and history_revisions tables are
created if missing, so pointing at a fresh database just works. opts.fs
forwards a filesystem provider to the SQLite backend for virtual or
non-default filesystems.
Methods
async loadHistory(revisionId: string): Promise<MessageHistory | null>
Rebuild the history graph reachable from a revision id by walking its
stored lineage, or return null if the revision is unknown.
async loadThread(threadId: string): Promise<ThreadState | null>
Load a thread head, or null if the thread id is unknown.
async commitSession(args: {
run: RunState;
thread: ThreadState;
history: MessageHistory;
baseRevisionId?: string;
}): Promise<void>
Persist a checkpoint in one transaction: history delta since
baseRevisionId, run row, and thread head. Throws without writing if
the run or thread revision pointers disagree with the committed
history's head revision.
async save(s: RunState): Promise<void>
Write a run checkpoint directly, without touching thread or history.
async loadRun(runId: string): Promise<RunState | null>
Load a run checkpoint, or null if the run id is unknown.
async load(runId: string): Promise<RunState | null>
Alias of loadRun().
async listRuns(filter: {
threadId?: string;
} = {}): Promise<RunState[]>
List run checkpoints, most recently updated first, optionally scoped to one thread.
async list(filter: {
threadId?: string;
} = {}): Promise<RunState[]>
Alias of listRuns().
async deleteRun(runId: string): Promise<void>
Remove a run row; history entries and threads are untouched.
async delete(runId: string): Promise<void>
Alias of deleteRun().
async close(): Promise<void>
Close the underlying database connection.
class Session {
Durable runner for an agent thread.
A Session binds an Agent to a SessionStore 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 { session, SqliteSessionStore } from 'fino:ai/session';
const store = await SqliteSessionStore.open('./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 a Memory, recalled context (prior messages, semantic hits,
working memory) is folded in ahead of the input and the input is appended
to memory.
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); // true
async 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
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 { session, SqliteSessionStore } from 'fino:ai/session';
const store = await SqliteSessionStore.open('./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?');