picoflow.io Docs

Concepts

Step lifecycle

When each Step hook runs: configuration, tool registration, activation, model-call preparation, response handling, recovery, and completion.

The Step lifecycle includes configuration, tool registration, activation, execution, recovery, and completion. The framework calls your override hooks at different points in that sequence; helpers such as saveState() and runStep() are operations your code chooses to call.

Five activation hooks decide when a step gets to prepare itself. They look similar and they are not interchangeable. The rule that resolves almost every question is: entry hooks fire on activation, restore fires on rehydration, and crossing fires on message hand-off — and those are three different events.

Hook map

This table covers every override hook in the Step reference. It describes timing; the reference holds signatures and return contracts.

Phase Hook When the framework calls it
Configuration configLlmCallPolicy() During ordinary Step bootstrap validation, and when resolving effective policy for model calls; inherits the Flow policy
Execution-kind checks isLogic() During model validation and runner dispatch; extend LogicStep rather than override it directly
Tool registration defineTool() During Flow tool composition after hydration, for every registered Step; DecisionSteps also query it during configuration validation
New session onStart() Once for the initial Step of a newly created session
Restore onRestore() On the current Step when a running session is rehydrated
Activation onEnter() Via default onStart(), on entry through a different top-level Step, or before nested execution
Message hand-off onCrossing() Via default onStart() and the runner’s cross-step message path
Model-call preparation useTool() While resolving exposed tools or checking tool availability; DecisionSteps also query it to reject chat-tool configuration
Model-call preparation getPrompt() When preparing the chat-model call; also used by the Decision runner
Model-call preparation structOutputSchema() After binding tools, before invoking the chat model; DecisionSteps also query it to reject structured chat output
Candidate validation checkResponse() For a usable, unblocked model candidate, before tool dispatch or onResponse()
Final model response onResponse() After a candidate is accepted and has no tool calls
Invocation-error retry shouldRetryLlmError() When an ordinary chat-model invocation throws
Terminal model recovery onLlmError() When chat-model retries are declined or exhausted without an accepted candidate
Provider refusal onLlmBlocked() When the chat provider refuses or blocks the prompt or candidate
Deactivation onExit() When leaving a top-level Step, or in nested-execution cleanup
Completion reporting isEnd() On the current Step when Flow builds the turn’s response envelope

Some hooks are queried repeatedly. Keep configuration and selection hooks free of external side effects; do not treat them as once-per-session initialization. The activation scenarios below explain entry and exit timing, followed by the execution and recovery paths.

Configuration and tool registration

For each engine invocation, the Flow constructs its registered Steps through defineSteps(). Before session hydration, it resolves model selections and validates ordinary Steps’ effective call policies. configLlmCallPolicy() supplies a Step override of the Flow policy; it can be queried again during execution, so it must not assume hydrated state during bootstrap. Step model overrides come from useModel() and persisted model settings rather than a Step configModel() override hook.

After the initial Step’s onStart() or the restored current Step’s onRestore(), the Flow composes its tool registry. It calls defineTool() on every registered Step, then on the Flow, and combines those definitions into a shared registry. A Step does not have to become active for its tools to be registered. Registration repeats on each engine invocation.

defineTool() declares tools for the shared registry; useTool() selects named tools for the executing Step. Its own decorated @Tool and @Tools declarations are included automatically. A tool defined elsewhere is exposed by naming it in useTool(). See Decorators for declaration and handler details.

Activation hooks

Hook Visibility Default behaviour
onStart() public Calls onEnter(), then returns onCrossing(null)
onRestore() public No operation
onEnter() protected No operation
onExit() protected No operation
onCrossing(message, priorStep?) public Synthesises a HumanMessageEx("Start") when there is no incoming message and the last message in this namespace did not come from this step; otherwise passes the message through

Scenario 1: new session

A request arrives with no session ID, or with an ID that is missing, completed, aborted, or rejected by that Flow’s restore policy.

Flow creates step documents, currentStep = initialStep()
  -> initialStep.onStart()
       -> onEnter()
       -> onCrossing(null)
  -> the resulting message is pushed into the step's memory namespace
  -> session is saved
  -> Step.run(userMessage)
       -> resolve call policy and model
       -> obtain selected tools (useTool() + decorated tools)
       -> getPrompt()
       -> structOutputSchema()
       -> model call / retry / recovery
            -> accepted candidate: checkResponse() returns false
                 -> tool calls: @Tool handler -> stay/go -> continue or cross
                 -> no tool call: onResponse()
  -> persist currentStep, step state, memory, model overrides, session

Only the initial step gets onStart(). Other registered steps sit in the document with empty state and no hooks fired at all.

onCrossing(null) is what lets a flow open the conversation. Its default creates a synthetic "Start" human message so the model has something to respond to when the user’s first request carried no text — the pattern behind a bot that greets first.

Override onStart() only when the starting step needs bootstrap behaviour that differs from “enter, then cross”. Call super.onStart() unless you are deliberately replacing both.

Scenario 2: restored session

A request arrives with a valid, running session ID for the same flow.

Flow restores persisted step documents, memory, model settings and context
  -> currentStep.onRestore()
  -> currentStep.run(userMessage)
  -> normal prompt / model / tool / response lifecycle
  -> persist again
onStart() and onRestore() are mutually exclusive

onStart() is never called when an existing session is restored. onRestore() is never called for a new session. Setup that must happen exactly once per conversation belongs in onStart(); setup that must happen once per process belongs in onRestore().

Note what does not fire here: onEnter(). Restoring a session does not re-enter the current step. The step was already active when the last turn ended; the document simply records where it stopped.

That distinction matters when onEnter() has side effects. HotelFlow’s CompareStep calls eraseMemory() in onEnter():

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

If restoring also called onEnter(), every resumed turn would wipe the comparison history. It does not.

onRestore() is for rebuilding runtime-only resources — a cache, a client, a derived index — from persisted state, without repeating normal entry work.

Scenario 3: top-level step transition

A handler returns go(TargetStep), or onResponse() returns a step class.

current handler returns go(TargetStep)
  -> currentStep.onExit()
  -> sequence entry appended
  -> flow.currentStep = "TargetStep"
  -> targetStep.onEnter()
  -> session saved (mid-turn checkpoint)
  -> targetStep.onCrossing(message, priorStepName)
  -> targetStep.getPrompt()
  -> target model / tool loop

Three details are easy to get wrong.

The transition happens before the builder effects. withPrompt(...), withState(...) and withContentType(...) are applied to the destination after onEnter() has run. Code in onEnter() cannot read state that the transition is about to attach.

Transitioning to the current step is a no-op for entry hooks. Flow.goto(...) returns early when the target is already current. Since stay() is implemented as go(currentStep).withToolFeedback(...), a stay() does not fire onExit() or onEnter(), and does not fire onCrossing() either. That is what makes stay() cheap enough to use for every validation failure.

A direct message can skip onCrossing(). When a handler returns a direct AI message, the current HTTP invocation ends without another model call. The target is activated and onEnter() runs, but the normal cross-step model path — and therefore onCrossing() — may not.

onCrossing in detail

public onCrossing(
  message: MessageTypes | null | undefined,
  priorStep?: string,
): MessageTypes | null

It fires when the executing step differs from the step that was executing a moment ago, and its job is to decide what the destination model sees as its incoming message. Four useful behaviours:

Intent Implementation
Pass the user’s message through return super.onCrossing(message, priorStep)
Suppress it entirely return null
Replace it with a synthetic command return new HumanMessageEx(this, "Summarise the selected hotel.")
Branch on where the user came from Switch on priorStep

This is the right place for a stage that needs a starting instruction rather than the user’s literal words. A one-shot document step, for example, is entered with no useful user text and synthesises its own.

Do not put durable state changes here. It is a message transformation hook, and it can be reached more than once across a conversation.

Scenario 4: nested step execution

runStep(ChildStep, message?) and runSteps([...]) run a registered step inside the current turn without moving the cursor.

parent calls runStep(ChildStep, "message")
  -> sequence entry appended at level + 1
  -> child.onEnter()
  -> child.run("message")   (full prompt / model / tool loop)
  -> child.onExit()          (in a finally block)
  -> parent frame restored, child's content returned to the parent

runSteps([...]) creates one fresh, isolated Step instance per request and joins them through a bounded state barrier. Repeated classes are allowed with unique branch keys; fields written by several copies require a Step-owned reducer.

Nested execution is not a cross-step transition

The child is called directly, so onCrossing() is not invoked for it. Pass an explicit userMessage, or do the child's setup in onEnter(). Do not assume its onCrossing() will synthesise a starting message.

Children may call saveState(), and that state persists with the turn. They may not call goto():

Cannot goto 'SomeStep' from a child execution frame.
Return a result to the owning step instead.

Transition authority belongs to the owner. Parallel children receive private clones of the history visible at their fork, even when their canonical Steps use the same namespace. Raw child history is discarded rather than interleaved into the parent’s transcript.

Model-call and response lifecycle

The shared runner dispatches to chat, logic, or decision execution according to the Step’s kind. For an ordinary chat-model Step, one execution pass follows this order:

resolve Flow + Step call policy and effective model
  -> cross-step checkpoint and onCrossing(), when applicable
  -> obtainTools(): useTool() + decorated tool names
  -> getPrompt(): install the system message
  -> bind selected tools
  -> structOutputSchema(): apply structured output, when supplied
  -> invoke model within the configured attempt budget
       -> refusal: blocked-response recovery
       -> empty candidate: retry
       -> usable candidate: checkResponse()
            -> true: reject and retry
            -> false: accept
  -> accepted candidate with tools: dispatch handlers
       -> direct response: return without another model call
       -> otherwise: apply feedback / transitions and continue execution
  -> accepted candidate without tools: onResponse()
       -> final response, completion, or transition

getPrompt(), useTool(), and structOutputSchema() prepare a model-call pass, not each attempt inside its retry loop. A follow-up call after tool feedback or a transition prepares another pass. A temporary alternate model also prepares a new pass with the same Step.

checkResponse() runs before tool handlers, including for candidates containing tool calls. Returning true prevents those candidate tools from executing and requests another attempt; returning false accepts the candidate. It can run several times, so keep it deterministic and free of side effects. A thrown exception propagates outside model recovery.

onResponse() handles accepted responses without tool calls. It can rewrite output or return a transition or finish(...). Tool results and recovery results use normal response routing without first passing through onResponse().

Logic and decision execution

LogicStep executes stepLogic() without a chat-model call. Its activation and crossing behaviour still applies, but chat tool selection, structured output, candidate validation, and chat-model recovery hooks do not run. See LogicStep and terminal Steps.

DecisionStep uses getPrompt(), defineQuestions(), and decisionInput() to prepare a typed decision request; decisionInput() includes facts supplied by getDecisionFacts(). It merges options supplied through useDecision() with the Flow’s configDecision() defaults, validates the returned answers, and calls onDecision() on success. It does not use useTool(), structOutputSchema(), checkResponse(), or onResponse() for that decision. During configuration validation, it does query the inherited chat-tool and structured-output hooks to reject unsupported configuration. Its onDecisionError() path is described below; full contracts are in the DecisionStep reference.

Completion and persistence

At the end of normal Flow execution, the Flow reads isEnd() from its current Step to set the response’s completed field. The default checks whether the session’s runStatus is completed. isEnd() reports completion; returning true from a custom override does not itself mark the stored session completed. Use finish(...), sessionCompleted(), or the terminal Step’s completion behaviour to update that state.

The engine then saves the session, including Step state, model overrides, memory, and Flow context. onExit() is not an end-of-request hook: a conversational Step can remain current across many turns without exiting. See Flow persistence for save ordering and the completion guide for session status semantics.

Failure and recovery lifecycle

Activation is only part of the lifecycle. During an ordinary chat-model Step’s execution, PicoFlow also gives the Step a chance to retry or recover from model failures. Each hook runs on the executing Step first; delegation lets the Flow supply a shared policy.

model invocation
  -> ordinary invocation error
       -> Step.shouldRetryLlmError(context)
       -> undefined: Flow.shouldRetryLlmError(context)
       -> undefined: framework retry default
       -> retry if permitted and an attempt remains
  -> empty response or checkResponse() returns true
       -> retry if an attempt remains (no shouldRetryLlmError() call)
  -> retries declined or exhausted without an accepted response
       -> Step.onLlmError(context)
       -> null: Flow.onLlmError(context)
       -> both null: propagate the error

provider refusal or safety block (separate path)
  -> Step.onLlmBlocked(context)
  -> null: Flow.onLlmBlocked(context)
  -> both null: propagate the block

shouldRetryLlmError() returns true to use another configured attempt, false to stop, or undefined to delegate. It cannot extend the attempt budget. onLlmError() handles terminal invocation_error, empty_response, and response_rejected outcomes. It may return a final response, a normal transition, or retryWithModel(...) for one temporary alternate model. The alternate gets one attempt per model call and does not change the Step’s configured model for later turns.

onLlmBlocked() may return a response or normal transition. Refusals do not enter ordinary invocation-error retries or onLlmError() recovery. A handled result follows normal response and routing behaviour; recovering does not itself complete the session.

DecisionStep failures

DecisionStep uses a separate decision runner, not the chat-model failure hooks:

decision provider call / answer validation
  -> eligible provider retries within the configured decision budget
  -> failure remains
       -> DecisionStep.onDecisionError(context)
       -> null: Flow.onDecisionError(context)
       -> both null: propagate the error

A non-null result supplies a decision recovery response or transition. Decision maxRetries counts additional attempts after the first; chat retryAttempts counts total attempts. Invalid decision answers are not retried.

Errors outside model recovery

Cancellation bypasses recovery output. Errors in prompt construction, activation hooks, token accounting, tool handlers, or exceptions thrown by checkResponse() or onResponse() are outside the chat-model failure hooks. An unrecovered error reaches the engine’s failure boundary.

A tool may already have written state or caused an external effect before a later model call fails. Recovery should inspect that state or route to review rather than repeat the effect blindly. onExit() is not a general error handler: nested execution invokes it in its cleanup path, while top-level transitions invoke it when leaving a step.

See the Step recovery reference and DecisionStep recovery reference for signatures, context fields, and return contracts.

Activation summary table

Event onStart onEnter onCrossing onExit onRestore
New session, initial step yes yes (via onStart) yes (via onStart, with null) no no
Restored session, current step no no no no yes
go(Other) — leaving step no no no yes no
go(Other) — arriving step no yes yes no no
stay() no no no no no
runStep(Child) — child no yes no yes no
Direct response to another step no yes usually not yes no

Choosing an activation hook

Ask what the work depends on.

Depends on the conversation being brand new — onStart(). Seeding a first-turn message, recording a conversation-start event.

Depends on the process, not the conversation — onRestore(). Rebuilding a client, warming a cache, re-deriving a value you chose not to persist.

Depends on the step becoming active — onEnter(). Clearing memory, running a prerequisite child, resetting stage-scoped state. Remember it fires on every activation, so make it idempotent or accept that a user who navigates back re-runs it.

Depends on the step going inactive — onExit(). Releasing temporary resources, recording stage duration.

Depends on what the model should be told on arrival — onCrossing().

Tip

If you find yourself overriding run() to observe a request, you almost certainly want onEnter() or onCrossing() instead. run() owns the model loop; an override that forgets super.run(message) silently disables the step.