picoflow.io Docs

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 constructor takes only the flow

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 Signature Default Override when
configLlmCallPolicy() protected configLlmCallPolicy(): LlmCallPolicyOverride {} — inherits the Flow policy This Step needs another timeoutMs, or { timeoutMs: null } to remove the Flow deadline
getPrompt() public getPrompt(): string | null Returns the _prompt state value saved by .withPrompt(...), else null The step needs a system prompt
defineTool() public defineTool(): ToolType[] [] The flow needs a tool registered with a name, description, and Zod object schema
useTool() public useTool(): string[] [] The step exposes a tool defined elsewhere, or keeps an undecorated legacy handler
onStart() public async onStart(): Promise<MessageTypes | null> Calls onEnter(), then onCrossing(null) A new session’s starting step needs custom bootstrap
onRestore() public async onRestore(): Promise<void> No operation Runtime-only caches must be rebuilt on resume
onEnter() protected async onEnter(): Promise<void> No operation Setup runs every time the step becomes active
onExit() protected async onExit(): Promise<void> No operation Cleanup runs when the step is deactivated
onCrossing() public onCrossing(langMessage: MessageTypes | null | undefined, _priorStep?: string): MessageTypes | null 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() public async onResponse(llmResult: string | object): Promise<LastResponseType> JSON.stringify for objects, otherwise the value unchanged Free-form or structured output needs validation, rewriting, or routing
checkResponse() public checkResponse(_llmResult: string | object): boolean false A bad response should be retried — return true to retry
structOutputSchema() public structOutputSchema(): object | null null The provider should use constrained structured output
isLogic() public isLogic(): boolean false Never directly — extend LogicStep instead
isEnd() public isEnd(): boolean 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. Keep it deterministic; it can be evaluated more than once per turn.

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.