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?');