Reference
Step
The Step customization boundary: every override hook with its real signature, the state, memory, model and nested-execution helpers, and the runtime plumbing you should leave alone.
Step is the class you subclass for each stage of a workflow. It owns a prompt, a set of
exposed tools, its own persistent state, a memory namespace, an optional model override, and
the transitions it returns.
export abstract class Step {
static get id(): string; // defaults to the class name
protected constructor(flow: Flow);
protected get id(): string;
public getName(): string; // returns id
}
The signature is protected constructor(flow: Flow). The initial cursor comes from the order of defineSteps() or from Flow.initialStep(); it is not selected by a constructor flag.
Override hooks
| Hook | Default | Override when |
|---|---|---|
configLlmCallPolicy() |
{} — inherits the Flow policy |
This Step needs another timeoutMs, or { timeoutMs: null } to remove the Flow deadline |
getPrompt() |
Returns the _prompt state value saved by .withPrompt(...), else null |
The step needs a system prompt |
defineTool() |
[] |
The flow needs a tool registered with a name, description, and Zod object schema |
useTool() |
[] |
The step exposes a tool defined elsewhere, or keeps an undecorated legacy handler |
onStart() |
Calls onEnter(), then onCrossing(null) |
A new session’s starting step needs custom bootstrap |
onRestore() |
No operation | Runtime-only caches must be rebuilt on resume |
onEnter() |
No operation | Setup runs every time the step becomes active |
onExit() |
No operation | Cleanup runs when the step is deactivated |
onCrossing() |
Synthesises HumanMessageEx(this, 'Start') when there is no incoming message and history does not already end on this step |
A stage must rewrite, replace, or suppress the crossing message |
onResponse() |
JSON.stringify for objects, otherwise the value unchanged |
Free-form or structured output needs validation, rewriting, or routing |
checkResponse() |
false |
A bad response should be retried — return true to retry |
onLlmBlocked() |
null — delegate to the Flow |
The provider refuses or blocks a prompt or candidate and the Step can return a safe response or route |
shouldRetryLlmError() |
undefined — delegate to the Flow, then framework default |
A thrown invocation error should stop early or use another configured attempt |
onLlmError() |
null — delegate to the Flow |
Exhausted or declined model work needs a final response, route, or one temporary alternate model |
structOutputSchema() |
null |
The provider should use constrained structured output |
isLogic() |
false |
Never directly — extend LogicStep instead |
isEnd() |
flow.getSessionDoc().runStatus === 'completed' |
A specialised terminal step reports completion differently |
checkResponse() has inverted semantics on purpose: false accepts, true asks the retry
loop to run again. It runs before tool dispatch, so a rejected candidate’s tool calls are not
executed. Keep it deterministic and side-effect free; it can be evaluated more than once per
turn.
Configuration, activation, and response signatures
// Configuration and model-call preparation
protected configLlmCallPolicy(): LlmCallPolicyOverride;
public getPrompt(): string | null;
public defineTool(): ToolType[];
public useTool(): string[];
public structOutputSchema(): object | null;
// Activation and restoration
public async onStart(): Promise<MessageTypes | null>;
public async onRestore(): Promise<void>;
protected async onEnter(): Promise<void>;
protected async onExit(): Promise<void>;
public onCrossing(
langMessage: MessageTypes | null | undefined,
_priorStep?: string,
): MessageTypes | null;
// Candidate validation, response handling, and execution kind
public checkResponse(llmResult: string | object): boolean;
public async onResponse(llmResult: string | object): Promise<LastResponseType>;
public isLogic(): boolean;
public isEnd(): boolean;
For the order and frequency of these calls, see Step lifecycle. Recovery signatures are listed separately below.
Model refusal, retry, and recovery hooks
The three model-failure hooks have distinct jobs and precedence. They apply to ordinary
chat-model Steps; DecisionStep uses its separate
onDecisionError() chain.
onLlmBlocked()
public async onLlmBlocked(
context: LlmBlockedContext,
): Promise<LlmBlockedResponse | null>;
Handles a provider refusal or safety block. The context includes provider, normalized
reason, provider rawReason, phase: "prompt" | "candidate", and optional safety ratings
or provider details. Returning a string or normal Step transition handles the block. Returning
null delegates to Flow.onLlmBlocked(); when both return null, PicoFlow propagates the
block.
Blocked responses do not enter ordinary invocation-error retry or onLlmError() recovery.
Use this hook for an honest safe response or a bounded application route, not to disguise a
provider refusal as a successful model answer.
shouldRetryLlmError()
public shouldRetryLlmError(
context: LlmAttemptErrorContext,
): boolean | undefined;
Runs only when the model invocation throws an ordinary error. The context contains Step ID,
provider, model, original Error, one-based attempt, maxAttempts, and the request signal.
trueuses the next configured attempt when one remains;falsestops immediately and proceeds to terminal recovery;undefineddelegates toFlow.shouldRetryLlmError(), then the framework’s ordinary-error default.
The hook cannot extend the attempt budget or change the delay, model, or deadline.
retryAttempts is the total model-call attempt budget, with a framework default of three.
onLlmError()
public async onLlmError(
context: LlmErrorContext,
): Promise<LlmErrorResponse | null>;
Runs once after retry is declined or the attempt budget cannot produce an accepted response.
The Step’s non-null result wins; null delegates to Flow.onLlmError(). When both return
null, PicoFlow propagates the existing error.
context.kind describes the terminal failure:
| Kind | Meaning | Additional fields |
|---|---|---|
invocation_error |
The provider invocation threw | original error; stoppedBecause is retry_declined or attempts_exhausted |
empty_response |
The provider returned no usable content or tool call | lastResponse; attempts exhausted |
response_rejected |
checkResponse() requested another candidate |
lastResponse; attempts exhausted |
The hook may return an ordinary final Step response, including routing or finish(...).
It may also return retryWithModel(selection) to continue the same Step temporarily with
another registered model. The alternate model gets one attempt per model call; if it calls a
tool, the follow-up model call remains on that alternate selection. The fallback keeps the
current prompt, memory, tools, structured output contract, request signal, and completed tool
history; it does not change the Step’s configured model for later turns. A second model fallback
in the same recovery sequence is rejected, and its selection may not include retryAttempts.
async onLlmError(context: LlmErrorContext) {
if (this.getState<boolean>('actionRecorded')) {
return directTo(ReviewStep, 'The action was recorded; review is pending.');
}
if (context.kind === 'invocation_error' && context.model === 'gpt-5.6-luna') {
return retryWithModel({
provider: 'openai',
name: 'gpt-5.1',
params: { reasoning: { effort: 'high' } },
});
}
return null;
}
Cancellation bypasses all recovery output, including cancellation while an asynchronous hook
is pending. Prompt construction, token accounting, tool handlers, checkResponse() exceptions,
and onResponse() exceptions are outside these model-failure hooks. A tool may already have
changed state or caused an external effect before a follow-up model call fails; recovery should
read the saved effect or route to review, never blindly repeat it.
run()
public async run(userMessage?: string): Promise<MessageContent | null>;
Wraps a non-empty userMessage in a HumanMessageEx and hands control to the shared model
runner. Override it only to prepare state, and call super.run(message) unless you are
deliberately replacing the whole model loop.
LastResponseType
onResponse() may return a string, a Step class, a registered step name, or a transition
object:
type LastResponseType =
| {
step: StepTarget;
message?: MessageTypes;
prompt?: string;
state?: JsonObject;
contentType?: HttpContentType;
}
| StepTarget; // a Step class or a registered step name
Returning a target activates it and continues execution in the same HTTP turn.
State and context helpers
| API | Signature | Purpose |
|---|---|---|
getState |
getState<T = JsonObject>(key?: string, stateType?: SaveStateType): T |
Read persistent state, or one lodash path inside it |
getTransientState |
getTransientState<T = JsonObject>(key?: string): T |
Read invocation-only state |
saveState |
saveState(json: JsonObject, stateType?: SaveStateType): void |
Replace the first top-level key in json, then merge; stamps _saveOn |
saveTransientState |
saveTransientState(json: JsonObject): void |
Save under _transient, never persisted |
removeState |
removeState(key: string): void |
Drop a durable key |
getContext |
getContext<T>(key: string): T |
Delegates to Flow.getContext |
SaveStateType.persistent is the default. saveState() reads Object.keys(json)[0], omits
that key from the existing state, and then merges — so passing a single top-level key replaces
that subtree rather than deep-merging into it.
Cross-step access goes through the flow: flow.getStepState(OtherStep),
flow.saveStepState(OtherStep, json), flow.saveTransientStepState(OtherStep, json).
Memory and message helpers
| API | Signature | Purpose |
|---|---|---|
useMemory |
useMemory(nameSpace: string): this |
Select the namespace; validated against /^[A-Za-z][A-Za-z0-9_-]{0,127}$/ |
getMemorySpace |
getMemorySpace(): string |
The selected namespace; defaults to the step’s id |
getMemory |
getMemory(): MessageTypes[] |
The live history array; seeds an empty SystemMessage slot at index 0 |
getLastMessage |
getLastMessage(): MessageTypes | null |
The newest message, without seeding |
eraseMemory |
protected eraseMemory(): MessageTypes[] |
Truncate the namespace in place |
genMessageId |
genMessageId(): string |
Step name, UTC timestamp, and a 10-digit suffix joined by pipes — the format crossing detection parses |
The runner overwrites history[0] with the system message built from getPrompt() on every
model call, which is why the placeholder slot exists. Message IDs carry step attribution, so
custom raw LangChain messages should always use genMessageId(). HumanMessageEx,
AiMessageEx, ToolMessageEx, and DirectMessage do this for you.
Erasing history does not erase step state. During runSteps(), getMemory() is an
invocation-private clone of the history visible at the fork. Nested children inherit the
calling branch’s current clone; raw child history is discarded after the branch completes.
Model and output helpers
| API | Signature |
|---|---|
useModel |
useModel<const Provider extends string, const Name extends string>(selection: ModelSelectionFor<Provider, Name>): this |
getModel |
getModel(): string | undefined |
getModelSelection |
getModelSelection(): ResolvedModelSelection |
getLlmCallPolicy |
getLlmCallPolicy(): LlmCallPolicy |
getLLMType |
getLLMType(): LLMType |
contentType |
get contentType(): HttpContentType / set contentType(ctType: HttpContentType) |
useModel() marks a real override. getModelSelection() merges params with the flow’s only
when the provider and name are identical; a cross-model override replaces params
entirely. An override equal to the flow selection is not persisted on the step document.
getLlmCallPolicy() resolves the Step override against the Flow policy. An omitted
timeoutMs inherits the Flow value, a positive integer replaces it, and null removes
it. This code-owned policy is not part of the persisted model selection.
getLLMType() maps the resolved model name prefix to LLMType.GEMINI, LLMType.OPENAI,
LLMType.ANTHROPIC, or LLMType.UNSUPPORTED, and is used for provider-side file uploads.
contentType defaults to HttpContentType.Plain. Prefer .withContentType(...) on a
transition over assigning it directly — see go() / stay() / direct().
Nested execution and completion
| API | Signature | Purpose |
|---|---|---|
runStep |
runStep(stepClass: StepClassType, userMessage?: string): Promise<MessageContent | null> |
Run one registered child in an in-memory frame |
runSteps |
runSteps(requests: readonly RunStepRequest[], options?: RunStepsOptions): Promise<ParallelBatchResult> |
Run isolated child instances through a bounded fork/join barrier |
sessionCompleted |
sessionCompleted(): void |
Set runStatus to completed |
isEnd |
isEnd(): boolean |
Report completion for the response envelope |
type RunStepRequest = {
step: StepClassType;
key?: string;
params?: JsonObject;
userMessage?: string;
};
type RunStepsOptions = {
failurePolicy?: "retain-successes" | "atomic";
checkpoint?: "none" | "root-join";
maxConcurrency?: number;
signal?: AbortSignal;
};
runSteps() creates a fresh worker for every request internally. There is no decorator,
factory hook, or other caller-side registration syntax: pass the same Step classes that are
already registered with the Flow. parallelParamsSchema() may return a Zod-compatible
validator for params; getParallelInvocation() exposes the frozen params, branch key,
request index, scope path and cancellation signal inside the worker.
One class may appear repeatedly. Every repeated request then needs a non-empty, batch-unique
key. Each invocation has private Step state and memory. A successful worker’s saveState()
becomes a proposed replacement of its own top-level field. Multiple branches replacing the
same field conflict; declare a reduced channel and call contributeState() when combining
copies is intentional:
protected override parallelStateChannels() {
return {
total: StepChannels.sum(),
resultByKey: StepChannels.keyedByBranch(),
};
}
public override async run() {
const { params } = this.getParallelInvocation<{ amount: number }>();
this.contributeState("total", params.amount);
this.contributeState("resultByKey", params.amount);
return params.amount;
}
Built-in channel helpers are singleWriter(), reduced(...), sum(), append(),
appendUniqueBy(...), keyedByBranch(), and mergeRecord(). Reducers run in stable request
order, never completion order.
ParallelBatchResult.branches, .fulfilled, and .rejected remain in request order.
The default retain-successes policy publishes fulfilled state even when another child
fails. atomic publishes none when any child rejects. Framework failures—such as a shared
mutation, invalid JSON, factory error, conflict, or reducer failure—throw and publish none of
that barrier’s application state.
Publication happens before runSteps() returns, so the caller immediately sees child state
through getStepState() and getSessionDoc(). The default checkpoint: "none" waits for the
normal outer save; root-join performs one immediate session save and rolls the in-memory
application state back if that requested checkpoint fails.
Workers receive read-only snapshots of the session document, context, and other Steps.
Attempts to mutate those surfaces, move the cursor, complete the session, replace Flow
memory, or call saveSession() raise ParallelMutationError. The worker may change only its
own private state and private memory. Raw child histories are discarded; published Step state
and returned output are the communication mechanism.
Nested runStep() and runSteps() fork from the calling branch’s materialized view. Thus a
parent worker’s pre-fork state is visible to its children, inner successful state is visible
to that parent at the inner join, and descendants reach canonical state only if the containing
outer branch succeeds.
Cancellation is cooperative. Running model calls and workers receive the signal, queued work
does not start, and a worker that ignores cancellation loses publication rights after the
Flow’s cancellationGraceMs deadline. Arbitrary external side effects are not rolled back;
make them idempotent by branch key.
A sequential runStep() keeps its existing behavior. When called inside a parallel worker it
uses a one-child atomic barrier and receives the same isolation guarantees.
Nested execution increments the sequence level recorded in the session document but never
moves the durable cursor.
For normal user-facing completion, transition to TerminateSessionStep. Use
sessionCompleted() for workers and coordinators that finish without a closing conversation.
See Nested execution.
Runtime plumbing — do not override
These are public because the runtime shares the class. Application steps should not call or override them:
- Execution frames:
pushExecutionFrame(),Step.hasExecutionScope(),Step.getCurrentExecutionStep(),enterCurrentStep(),exitCurrentStep(). - Persistence:
createDoc(),readDoc(stepDoc),writeDoc(stepDoc). - Tool dispatch:
obtainTools(),isToolAvailable(name),hasToolHandler(nameOrNames),invokeToolHandler(toolOrTools). - Model plumbing:
inheritModel(model). - Identity:
getName(),getMemorySpace().
invokeTool(_tools: ToolCall[]): Promise<[string, boolean]> still exists on the class and
returns ['', false], but the runner never calls it. Dispatch goes through
invokeToolHandler(...). Do not build anything new on invokeTool.
Override isLogic() only by extending LogicStep;
claiming an ordinary Step is logic-backed bypasses the runLogic() contract and will fail
in LogicRunner.