picoflow.io Docs

Concepts

State, memory, context, transient

PicoFlow stores four different kinds of data with four different lifetimes. Choosing the wrong one is the most common source of bugs that only appear on the second turn.

Every value your flow handles belongs in exactly one of four places. They are easy to confuse because three of them survive a turn and two of them are keyed by step. Get this page right and most of PicoFlow’s surprising behaviour stops being surprising.

The four at a glance

What it is Scope Lifetime Persisted Primary API
State Arbitrary durable JSON One step Until overwritten or the session ends Yes saveState / getState
Memory LangChain conversation history One namespace, shared by any step that selects it Until erased or compacted Yes useMemory / getMemory
Context Session-wide configuration The whole flow Set once, at session creation Yes getContext
Transient Scratch data One step One HTTP invocation No saveTransientState

The quickest decision procedure:

  • Is it a domain value the conversation collected? → state, on the step that owns it.
  • Is it something the model needs to remember it said? → memory.
  • Was it supplied by the caller when the session started? → context.
  • Is it a handoff between two steps inside one request? → transient.

State

Durable, per-step JSON. This is where domain data lives: the captured date of birth, the selected hotel, the validated customer record.

Reading and writing

this.saveState({ email: "user@example.com" });

const email = this.getState<string>("email");
const all = this.getState();              // the whole state object
const city = this.getState<string>("criteria.city");   // dotted paths work
this.removeState("email");

getState<T>(key?) reads a nested key with a lodash-style path, or the entire object when called with no argument.

saveState replaces the first key and merges the rest

saveState is not a plain merge and it is not a plain assignment. It removes the first top-level key of the supplied object from existing state, then deep-merges the whole object in.

this.saveState({ criteria: { city: "Paris", nights: 3 } });
this.saveState({ criteria: { city: "Berlin" } });
// criteria is now { city: "Berlin" } — nights is gone.

That replacement behaviour is usually what you want: re-saving a structured value should not leave stale sub-keys behind. But it applies only to the first key.

this.saveState({ criteria: { city: "Berlin" }, filters: { wifi: true } });
// criteria is REPLACED. filters is deep-MERGED with whatever was there.
Save one top-level key per call

Multi-key saveState calls have inconsistent semantics: the first key is replaced, the rest are deep-merged. Call saveState once per top-level key and the behaviour is always "replace this key".

Every saveState also writes a _saveOn timestamp into the step’s state. _prompt is written the same way by .withPrompt(...). Both are framework keys; do not use those names.

Cross-step state

A step can read and write another registered step’s durable state through the flow:

const name = this.flow.getStepState<string>(NameStep, "name");
this.flow.saveStepState(AddressStep, { verified: true });

DOBStep does exactly this to personalise its prompt with the name that NameStep collected. The step class is the key, which is another reason class names are schema.

Tip

Prefer reading another step's state over duplicating the value into your own. Two copies of a customer record in one session document will diverge.

What state is not for

State is serialised to the session store as plain JSON. It is not a place for class instances, functions, or live handles. And because stores hydrate only session metadata, a Date you save comes back as a string. Store ISO strings or epoch numbers and parse them on read.

Memory

Persisted LangChain conversation history, partitioned by namespace. This is what the model actually sees as its message history.

Namespaces

A step selects its namespace at registration time:

protected defineSteps(): Step[] {
  return [
    new NameStep(this).useMemory("default"),
    new AddressStep(this).useMemory("default"),
    new DOBStep(this).useMemory("default"),
    new InContextStep(this).useMemory("separate"),
    new TerminateSessionStep(this).useMemory("temp"),
  ];
}

The default namespace is the step’s class name. A step that never calls useMemory(...) gets a private history under its own name.

Steps that share a namespace share history. That is the mechanism for conversational continuity across a multi-stage flow: NameStep, AddressStep and DOBStep above all read and write "default", so by the time the user reaches the date-of-birth stage the model can still see what they said about their name.

Separate namespaces isolate roles and keep tool traces from one stage out of another’s context window — which is both a quality decision and a cost decision.

Namespace names become persisted object keys, so they are validated: they must start with a letter, contain only letters, digits, _ and -, be at most 128 characters, and must not be __proto__, constructor or prototype.

Working with memory

API Purpose
useMemory(namespace) Select this step’s namespace. Chainable, used in defineSteps()
getMemory() The selected history array. Initialises its system-message slot
getLastMessage() The most recent message in the selected namespace
eraseMemory() Protected. Empties the selected namespace
genMessageId() A step-attributed ID for a custom LangChain message

Index 0 of a namespace is reserved for the system message; it is replaced with the result of getPrompt() before every model call.

Message IDs are step-attributed — the step name is the first segment — which is how PicoFlow knows which step produced a message when deciding whether a crossing needs a synthetic opener. Use genMessageId() when constructing raw LangChain messages, or use HumanMessageEx / AiMessageEx / DirectMessage, which handle it.

Erasing memory does not erase state

protected async onEnter() {
  this.eraseMemory();
}

HotelFlow’s CompareStep and PresentStep both do this: entering the stage starts a clean conversational slate, while every hotel, price and criterion collected so far remains in step state untouched.

Note

This separation is the practical benefit of keeping domain values out of the transcript. If the only record of the selected hotel is a sentence the model wrote, erasing memory loses it. If it is in PresentStep state, erasing memory costs nothing.

Compaction

Long conversations are compacted rather than truncated. Configure it on the flow’s memory container, in the constructor:

public constructor() {
  super();
  this.getMemory()
    .setSummaryModel({ provider: "openai", name: "gpt-4o" })
    .setSummaryConfig({ minMessages: 8, recentMessages: 4 })
    .enableSummary("hotel-explore");
}

Compaction runs at the end of a turn, before the session is written. Older messages in an enabled namespace are replaced by a rolling summary; recentMessages newest messages are kept verbatim. Defaults are 16 and 8. The summary is stored in the document alongside the remaining messages and injected as a second system message on later calls.

Compaction is opt-in per namespace, and its model must resolve through a registered provider like any other. A failure is recorded as a session warning and does not fail the turn.

Context

Session-wide configuration, established once when the session is created.

// caller
{ "flowName": "BasicFlow", "message": "Hi", "config": { "isPresident": true } }
// flow or step
const isPresident = this.getContext<boolean>("config.isPresident");

The request’s config object is stored under a config key, which is why every read is prefixed with config.. Reads use lodash paths, so nested values work: getContext<string>("config.tenant.region").

Context is available before the first model call, which makes it the only place a value can influence initialStep():

protected initialStep() {
  return this.getContext<boolean>("config.isPresident")
    ? PresidentStep
    : WeatherStep;
}

A new config does not reconfigure a restored session

Context is set once per session

The request's config is merged into the flow's in-memory context during construction, but restoring an existing session then overwrites that context with the stored value. Sending a different config on turn two has no effect and produces no error.

This is deliberate. Context describes the session — tenant, locale, document to process, feature flags — and letting a later request silently change it would mean a conversation’s own assumptions could shift underneath it.

If configuration must change, choose one of:

  • start a new session with the new config, which is correct for anything immutable such as tenant or document identity; or
  • implement an explicit, validated step that changes the value as a durable state transition you can see in the session document.

The flow also exposes addContext(json) and setContext(object). They mutate the in-memory context and are persisted with the turn, but they are runtime plumbing — reach for them in migration or coordinator code, not as a substitute for the two options above.

Transient state

Invocation-only scratch data, deliberately omitted at persistence.

this.saveTransientState({ pricingResponse });
const pricing = this.getTransientState<PricingResponse>("pricingResponse");

this.flow.saveTransientStepState(CompareStep, { candidates });

Transient values are held under a _transient key inside the step’s in-memory state and are explicitly stripped when the step is written to the session document. They never reach the store.

Use it for:

  • passing a large intermediate result from a parent step to a nested child within one request;
  • caching an expensive computation across the several model calls of a single tool loop;
  • holding anything you must not persist — raw file bytes, a decrypted value, a third-party response you have no retention right to.

Do not use it for anything the next turn needs. There is no error when a transient value is missing on the following request; getTransientState simply returns undefined, and the symptom is a step that behaves correctly on turn one and mysteriously on turn two.

Tip

Transient state pairs naturally with runStep(). The parent computes something expensive, writes it transiently to the child with flow.saveTransientStepState(ChildStep, ...), runs the child, and nothing bloats the session document.

Putting it together

A hotel search turn touches all four:

@Tool
protected async search_hotels(args: Record<string, any>): Promise<ToolResponseType> {
  // context: which tenant's catalog to search — fixed for the session
  const tenant = this.getContext<string>("config.tenantId");

  const criteria = { city: args.city, checkIn: args.checkIn, nights: args.nights };
  const results = await this.catalog.search(tenant, criteria);

  if (results.length === 0) {
    // memory: the model reads this feedback and asks the user to widen the search
    return stay("No hotels matched. Ask the user to relax the budget or dates.");
  }

  // state: durable, owned by this step, survives memory erasure
  this.saveState({ criteria });
  this.saveState({ resultIds: results.map((r) => r.id) });

  // transient: the full result objects are large and re-derivable
  this.flow.saveTransientStepState(PresentStep, { results });

  return go(PresentStep).withPrompt("Present the search results.");
}

State keeps what the conversation must not lose. Memory keeps what the model must remember saying. Context keeps what the caller decided. Transient keeps what only this request needs.