picoflow.io Docs

Concepts

The session document

One JSON document holds everything a conversation needs to resume: identity, status, token accounting, structured logs, and a single flow envelope. Writes are guarded by an integer revision.

A PicoFlow session is a single document. Not a set of rows, not a checkpoint stream — one JSON object, loaded whole at the start of a turn and written whole at the end. That choice is what makes the persistence model easy to reason about, and it is also why concurrent writes need an explicit guard.

An operational record, not just saved state

The same document carries the customer/model history, durable business state, active step, transition sequence, effective model details, token totals, run status, and structured diagnostics. Keep it in your approved database, inspect it during an incident, query it for operations, or copy and redact it into an isolated development environment for safe reproduction. PicoFlow does not require a separate PicoFlow observability service for this baseline.

The document tree

Session document
├── id                  session identifier (UUID v4)
├── revision            compare-and-swap token, incremented on every write
├── version             session-schema version
├── createdOn           creation timestamp
├── saveOn              last successful write
├── runStatus           'running' | 'completed' | 'aborted'
├── tokens              provider-neutral token accounting
├── log / error / warn / debug / verbose
└── flow                exactly one — not flows[]
    ├── name            permanently bound to this session ID
    ├── model           { provider, name, params, retryAttempts? }
    ├── context         session-wide config
    ├── memory          namespace -> conversation history
    ├── steps           [{ name, state, model? }]
    ├── currentStep     the one durable cursor, or null
    └── sequence        [{ level, stepName }]

Top-level fields

Field Type Notes
id string Generated as a UUID v4 when the session is created.
revision integer Incremented by the store on every successful write. This is the concurrency token, not a schema version.
version number The framework’s session-document schema version, stamped on every normal save.
createdOn Date Set once.
saveOn Date Updated on every save; the baseline for any Flow-owned idle policy.
runStatus enum running, completed, or aborted.
tokens object Cumulative token counters for the whole session.
log, error, warn, debug, verbose arrays Structured entries written by SessionLogger.

runStatus

running is the normal state. TerminateSessionStep sets completed, as does an explicit sessionCompleted() call on a step. An unhandled flow error sets aborted and attempts to persist the error before returning success: false.

Neither terminal status resumes. A request that presents a completed or aborted session ID gets a brand-new session document with a new ID, rather than an error.

Token accounting

tokens is provider-neutral. inputTokens, outputTokens and totalTokens are the billable totals; the remaining fields are breakdowns of those totals, retained because providers price them differently.

Field Relationship
inputTokens Total input charged, including cached input where reported
outputTokens Total output charged, including reasoning/thinking tokens
totalTokens Combined
reasoningTokens A subset of outputTokens. OpenAI calls these reasoning tokens; Gemini and Claude call them thoughts/thinking
visibleOutputTokens outputTokens less reasoningTokens
cachedInputTokens A subset of inputTokens — cache reads
cacheCreationInputTokens A subset of inputTokens — cache writes

Counters accumulate across every model call in the session, including the repeated calls inside a tool loop and any memory-summary calls.

The flow envelope

There is exactly one, and it is an object, not an array. See One flow per session for why that matters.

name

The registered flow name. It is validated on every load: the requested flowName must equal this value, or the request fails with SESSION_FLOW_MISMATCH before any restore logic runs.

model

The flow’s resolved default model as { provider, name, params, retryAttempts? }. It is written on every save and read back on restore, which means a restored session keeps the model it started with even if configModel() has since changed in source. Step-level overrides are stored separately, on the step document.

context

Session-wide configuration. Initialised from the first request’s config object, stored under a config key, and read with getContext<T>("config.something").

Context is not re-read from later requests. Sending a different config on a restored session does not reconfigure it.

memory

A map from namespace to conversation history:

memory: {
  "hotel-explore": {
    messages: [ /* serialized LangChain messages */ ],
    summary: "The user is looking for a hotel in Paris ...",
    summarizedThroughId: "ExploreStep|2026-02-11T09:14:02.113Z|4821990312",
  },
}

Each message carries id, type (system | human | ai | tool), content, timestamp, name, and the tool-call fields where relevant. Message IDs are step-attributed — the step name is the first segment — which is how crossing detection and persistence attribute a message to its owner.

summary and summarizedThroughId appear only for namespaces where compaction is enabled.

steps

One entry per registered step:

steps: [
  { name: "ExploreStep", state: { criteria: { city: "Paris" } } },
  { name: "PresentStep", state: {}, model: { provider: "openai", name: "gpt-4o", params: { temperature: 0.5 }, retryAttempts: 3 } },
]

model is present only when the step’s effective selection differs from the flow default. A step that inherits the flow model stores no model key at all, which keeps the document small and makes an override visible at a glance.

Two keys inside state are written by the framework rather than by your code:

Key Written by Meaning
_saveOn Every saveState(...) call Timestamp of the last state write for that step
_prompt .withPrompt(...) on a transition The system prompt the destination step should use; the base getPrompt() returns it

Transient state is stored in memory under a _transient key and is explicitly stripped when the step is written to the document. It never reaches the store.

currentStep

The durable cursor: a step-name string, or null before a flow has established one. There is no active flag on a step document. A terminal session can retain the name of its terminal step (for example, TerminateSessionStep); use runStatus, not currentStep alone, to decide whether a session can resume.

sequence

An append-only execution trail:

sequence: [
  { level: 1, stepName: "ExploreStep" },
  { level: 1, stepName: "PresentStep" },
  { level: 2, stepName: "PriceCheckStep" },
]

level is the nesting depth. Level 1 entries are top-level goto transitions. Deeper levels are runStep(...)/runSteps(...) children, which execute inside the parent’s turn and never move the cursor. This is the record you read when asking “how did this conversation get here”.

Legacy documents that stored bare strings are normalised to level 1 on load.

Dates are only partially hydrated

JSON stores serialise Date values to strings. On load, PicoFlow deliberately revives only session metadata: createdOn, saveOn, and the integer revision.

Everything under flow — including your step state — is left as the store returned it.

Warning

A Date you write with saveState({ bookedOn: new Date() }) comes back as a string on the next turn. Store timestamps as ISO strings or epoch numbers, and parse them explicitly when you read them. Migration code must make the same assumption.

Revision compare-and-swap

Every document carries an integer revision. It is the write token, and it is the reason two concurrent requests cannot silently lose each other’s work.

load document at revision 7
  -> run the turn and mutate the private loaded copy
  -> save(document, expectedRevision = 7)
       -> success: the store atomically writes revision 8
       -> stale or missing: SessionConflictError

Only one writer can win from a given revision. After every successful save, PicoFlow updates the in-memory document to the revision the store returned, so subsequent checkpoints in the same run — a cross-step save, an immediate migration save, the final save — all use a current token.

How each store implements it

Store Mechanism Deployment scope
Memory Compares the current in-memory revision before replacing a cloned document One process. Examples and tests only.
SQLite Atomic conditional UPDATE ... WHERE id = ? AND revision = ? Multiple connections or processes sharing the database file
MongoDB Atomic update filter on _id, flow name, and expected revision Distributed instances sharing MongoDB
Cosmos DB Expected revision plus an _etag IfMatch precondition Distributed instances sharing Cosmos DB

The MEMORY store is the default. It cannot coordinate writers in separate processes, and its contents vanish on restart. Set SESSION_STORE to SQLITE, MONGO or COSMO for anything durable.

Two layers, not one

Compare-and-swap is the second of two layers. The first is a per-session FIFO mutex inside FlowEngine, which serialises complete runs for the same session ID within one process. It covers the common case — a user double-tapping send — without producing conflicts at all.

The store’s compare-and-swap covers what the mutex cannot: a second server process, another engine instance, or a direct store writer. Keep both. See Concurrency and session conflicts.

Conflicts are surfaced, not resolved

A losing save throws SessionConflictError with code SESSION_CONFLICT and statusCode: 409. PicoFlow does not mark the session aborted, does not overwrite the winning document with the loser’s error, and does not replay the run.

Blind replay would be unsafe: the losing attempt may already have called a model, sent a message, uploaded a file, or charged a card before it lost the save. Session compare-and-swap protects the document; it cannot roll back an external side effect. Make tool side effects idempotent before you allow a retry.

The schema version

version is stamped with the framework’s current K.sessionDocVersion on every normal save. It exists so onRestoreSessionDoc() can make migrations ordered and idempotent.

It is not the same as revision. revision changes on every write; version changes only when the document’s schema changes.

Never silently accept a newer version

If a stored document's version is higher than the running code's, a later normal save will stamp it back down and may destroy fields this build does not understand. Reject or reset explicitly.