HotelFlow tutorial
6. Branch, forward, and return
A user who says "compare 2 and 5 on price" should not have to say it twice. This lesson covers the three mechanisms that move a request across a step boundary without asking the user to repeat themselves.
PresentStep is a junction. From a single result list the user can book,
search again, or compare — and two of those three targets need to know what the
user actually said. PicoFlow gives you three tools for that: onCrossing() to
seed a step with a synthetic message, .withMessage(...) to forward a real
one, and flow.saveStepState(...) to prime a step’s state before you enter it.
The goal
- Seed a newly entered step with a synthetic request using
onCrossing(). - Forward the user’s own text across a transition with
.withMessage(this.getLastMessage()). - Prime a destination step’s state before entering it with
flow.saveStepState(...). - Return from a branch and re-enter the step you left.
What happens on a step boundary
When a turn’s execution moves from one step to another, the runner detects it before building the request:
const history = step.getMemory();
if (priorStep.getName() !== step.getName()) {
await flow.saveSession();
langMessage =
step.onCrossing(langMessage, priorStep.getName()) ?? undefined;
}
Three things follow from those four lines.
- The session is persisted at every genuine step boundary, so a crash between stages does not lose the transition.
onCrossing()receives whatever message the transition carried — possiblynull— and returns the message the new step will actually see.- It does not run on a self-transition, because the names match.
The default implementation, on Step, injects a "Start" message when there
is nothing else to say and the last message in memory came from another step.
Overriding it lets a step decide what its own first turn looks like.
Seeding a step with a synthetic message
From pico-demo/src/myflow/hotel-flow/present-step.ts:
public onCrossing(
_userMessage: MessageTypes,
_priorStep?: string,
): MessageTypes {
return new HumanMessageEx(this, "What hotels choice I have");
}
Both parameters are ignored — the underscore prefixes say so. Whatever the
transition carried, PresentStep always begins with the same synthetic
request, which combined with its prompt produces the numbered result list.
That is the right choice here, because every path into PresentStep means the
same thing:
ExploreStep --capture_choices, results found--> "show me the list"
CompareStep --resume_booking-----------------> "show me the list"
HumanMessageEx is a thin HumanMessage subclass that stamps the message with
a step-scoped id, which is what the runner uses to tell whose history a message
belongs to:
export class HumanMessageEx extends HumanMessage {
constructor(step: Step, content: string, hint?: MessageHint) {
super({
content: content,
id: step.genMessageId(),
additional_kwargs: hint ?? { direct: false },
});
}
}
Build synthetic messages with HumanMessageEx(this, ...) rather than a raw
HumanMessage, unless you are also setting the id yourself — InvoiceFlow does
exactly that when it has to attach a file part.
Forwarding the user’s own words
search_again is the simplest transition in the flow:
@Tool
protected async search_again(): Promise<ToolResponseType> {
// go(...) changes steps; forward the request so ExploreStep can refine the search.
return go(ExploreStep).withMessage(this.getLastMessage());
}
getLastMessage() returns the newest message in this step’s memory
namespace:
public getLastMessage(): MessageTypes | null {
const mem = this.flow.getMemory(this.memorySpace);
if (mem.length > 0) {
return mem.at(-1) ?? null;
}
return null;
}
The builder attaches it to the transition, and the runner delivers it to the
destination’s onCrossing(). ExploreStep does not override onCrossing, so
the default returns it unchanged and the model sees the user’s actual sentence
against explore.md’s instructions.
This is what makes the scenario turn "change to a 2 bed rooms, search" work
in one turn. PresentStep recognises a re-search intent and calls
search_again; ExploreStep receives the same sentence, updates cRoomType
in the scaffold, and fires capture_choices — without a round trip that asks
the user what they wanted changed.
Forwarding a message into PresentStep would be pointless, because its onCrossing() discards the argument. Check the destination’s crossing behaviour before adding .withMessage(...), or you will spend a turn debugging a message that was thrown away on arrival.
Priming a step before you enter it
go_compare has more work to do, because CompareStep needs two lists: the
hotels the user picked and the hotels that were available to pick from.
@Tool
protected async go_compare(
args: Record<string, any>,
): Promise<ToolResponseType> {
this.flow.saveStepState(CompareStep, {
compare_hotel: args?.hotelsToCompare,
});
const availableHotel = this.flow.getStepState(
PresentStep,
"hotelFound",
) as [];
const strAvailableHotel = availableHotel.map((entry) => {
return entry["hotelName"];
}) as string[];
// go(...) enters comparison mode with the selected hotel data as destination state.
return go(CompareStep)
.withState({
available_hotel: strAvailableHotel,
})
.withMessage(this.getLastMessage());
}
Two different ways of writing to another step appear here, and the difference is timing:
this.flow.saveStepState(CompareStep, { compare_hotel: ... }); // now
go(CompareStep).withState({ available_hotel: ... }); // when the transition is applied
saveStepState reaches into the registered instance and calls saveState on
it immediately. .withState(...) is data attached to the returned builder that
the runner applies to the destination after flow.goto succeeds. Both end up
in CompareStep’s state bag before its prompt is built, so in this handler the
choice is stylistic. It stops being stylistic if the transition can fail or be
overridden downstream — saveStepState has already written by then.
Note also flow.getStepState(PresentStep, "hotelFound"). PresentStep is
reading its own state through the flow rather than through this.getState(...).
Both work; the explicit form is a useful habit in a handler that is already
reading and writing three different steps.
go_compare writes compare_hotel, but CompareStep.getPrompt() reads chosen_hotels. On the first comparison, ChosenHotels is therefore rendered as [] and only the forwarded message tells the model which hotels were meant. It works because generate_comparison later writes chosen_hotels itself, which is what makes the follow-up turn “compare on amenities” reuse the previous selection. If you are copying this pattern, make the writer and the reader agree on one key.
Reading the primed state back
From pico-demo/src/myflow/hotel-flow/compare-step.ts:
public getPrompt(): string {
const chosen_hotels = (this.getState("chosen_hotels") as []) ?? [];
const available_hotel = this.getState(`available_hotel`) ?? [];
let prompt = `
${ComparePrompt}
${FlowPrompt.EndChat}
`;
const hotels = chosen_hotels.map((entry) => {
return { hotelName: entry["hotelName"] };
});
prompt = Prompt.replace(prompt, {
ChosenHotels: JSON.stringify(hotels),
AvailableHotels: JSON.stringify(available_hotel),
});
return prompt;
}
compare.md is written as a four-state machine around those two variables. Its
rules are explicitly conditional on emptiness:
- If `ChosenHotels` is not empty, do not ask the user to choose hotels again.
- If `ChosenHotels` is not empty, skip State 1 and go directly to State 2.
- If `ChosenHotels` is not empty and the user already provided a valid feature,
skip State 2 and go directly to State 3.
A prompt written this way stays correct whether the step was primed or entered cold, which is the property you want when a step has more than one caller.
Returning
The way back is a plain transition with nothing attached:
@Tool
protected async resume_booking(): Promise<ToolResponseType> {
// go(...) returns to the booking-results step.
return go(PresentStep);
}
No state, no message. PresentStep still has hotelFound in its own state bag
from the original search, its onEnter() clears the stale presentation
history, and its onCrossing() supplies “What hotels choice I have”. The
result list is regenerated from data that never moved.
That is the payoff of the ownership discipline from lesson 1: because the
result list belongs to PresentStep, returning to it costs one line.
Why it is written this way
The three mechanisms cover three genuinely different needs.
| Need | Mechanism |
|---|---|
| The destination always starts the same way | onCrossing() returning a synthetic message |
| The destination must interpret this specific user request | .withMessage(this.getLastMessage()) |
| The destination needs data, not words | .withState(...) or flow.saveStepState(...) |
Keeping them separate means a step’s entry behaviour is defined on the step, in
one place, rather than being reconstructed from every handler that can reach
it. PresentStep guarantees its own opening line no matter who called it, and
callers cannot accidentally break that guarantee.
Common mistakes
- Forwarding a message to a step whose
onCrossing()discards it. Nothing breaks, but the request is silently lost. - Building synthetic messages with a bare
HumanMessage. Without a step-scoped id fromgenMessageId(), the runner cannot attribute the message. - Calling
getLastMessage()after switching namespaces in your head. It reads the current step’s namespace, not the conversation as a whole. - Priming a key the destination does not read. Exactly the
compare_hotel/chosen_hotelsmismatch above. - Assuming
onCrossing()fires on every turn. It fires only when the step actually changed during the turn. - Carrying data forward in the message text. Put data in state and words in the message.
Next
7. Answering without an LLM picks up
inside CompareStep, where the answer is a rendered table and no second model
call happens at all.