flightbooking
The flightbooking.example microservice is the suite’s showcase for the parts of the workflow engine that a synchronous LLM call cannot express: a real human-in-the-loop pause, graph composition, and a nested subgraph. Its BookFlight agent searches a route, proposes candidate flights one at a time, parks on an actual accept-or-keep-searching decision from the traveler, and — once a flight is accepted — delegates seat selection to a child LLM workflow before confirming the booking.
The teaching point is the pause. Where weather is a single-node agent and creditflow is human-in-the-loop by branching, flightbooking parks the flow on a genuine flow.Interrupt and resumes it later with an external decision via foreman.Resume. The flow stops, the durable state waits, and a separate request drives it forward — the shape you reach for whenever a workflow must wait on a human, an approval, or any event that arrives after the triggering request has already returned.
Depends On
foreman.core— the workflow engine that hosts theBookFlightflow, persists its state across the interrupt, and resumes it when the decision arrives.llm.core— the orchestrator that runs the seat-selection tool-calling loop. ThePickSeattask runs itsChatLoopworkflow as a subgraph.
The LLM is used only to refine the seat choice, so the headline — Interrupt/Resume, graph composition, the goto loop — is fully demonstrable without a provider key. If none is configured the seat subgraph errors, ChooseSeat catches it and falls back to the first available seat, and the human-in-the-loop booking still completes end-to-end. A configured provider adds the natural-language seat match on top. This is the deliberate difference from weather, whose entire output is the LLM answer and so cannot degrade.
Try It
With the examples app running:
http://localhost:8080/flightbooking.example/demo
The demo page defaults to a San Francisco → London search with the preference “a window seat near the front.” Submitting starts a BookFlight flow that parks on the first proposed flight; the page presents that flight with Accept and Keep searching buttons. Keep searching to advance to the next candidate, or accept to run seat selection and confirm the booking. The three seeded routes are San Francisco → London (three flights), New York → Paris (two flights), and Tokyo → Sydney (one flight); any other route exhausts immediately and books nothing.
The Demo page carries the flow key in a hidden form field across the Accept / Keep-searching buttons, so the traveler never copy-pastes it. A single stateless URL cannot round-trip an interrupt — the flow parks and must be resumed by its flow key — which is why the tour stop is a stateful web UI rather than a pair of clickable functional endpoints.
SearchFlights returns deterministic mock flights for a handful of routes rather than calling a real GDS, keeping the example self-contained. A production variant would wrap a booking API behind the HTTP egress proxy without changing the workflow’s shape.
Two Ways to Compose
The example teaches the graph-composition-versus-subgraph distinction on purpose:
- Graph composition —
SearchFlights,ProposeFlight,AwaitDecision,ChooseSeat,ConfirmBooking, andNoFlightsare all nodes in the oneBookFlightgraph. They share its full state vocabulary (candidates, flight index, current flight, seat) and hand off through transitions. - Subgraph —
ChooseSeatinvokes the separateChooseSeatAgentworkflow as an isolated child via the generatedflightbookingapi.NewSubgraph(flow).ChooseSeatAgent(...)client. Only the seat preference and available-seat list cross in, and only the chosen seat crosses back.ChooseSeatAgent’s ownPickSeattask in turn runsllm.core’sChatLoopas a further subgraph, so the seat choice is a genuine two-level nested composition.
The Workflow Graph
BookFlight is the top-level graph. It searches the route, then loops proposing one candidate at a time and parking on a human decision, delegating seat selection to the child workflow on acceptance. Its source (BOOKFLIGHT.mmd):
%%{init: {'themeVariables': {'lineColor': '#32a7c1', 'edgeLabelBackground': '#e5f4f3', 'textColor': '#434343', 'titleColor': '#32a7c1', 'clusterTextColor': '#32a7c1'}}}%%
graph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
_title{{"book-flight"}}:::term --> _start
_start(( )):::term --> t0
t0["SearchFlights"]:::task
t1["ProposeFlight"]:::task
t1_switch{"switch"}:::term
t2["AwaitDecision"]:::task
t3["ChooseSeat"]:::task
t4["ConfirmBooking"]:::task
t5["NoFlights"]:::task
t0 --> t1
t1 --> t1_switch
t1_switch -->|"exhausted"| t5
t1_switch -->|"default"| t2
t2 --> t3
t2 -->|"goto"| t1
t3 --> t4
t4 --> _end(( )):::term
t5 --> _end(( )):::termfunc (svc *Service) BookFlight(ctx context.Context) (graph *workflow.Graph, err error) {
graph = workflow.NewGraph("BookFlight")
graph.SetEndpoint("SearchFlights", flightbookingapi.SearchFlights.URL())
graph.SetEndpoint("ProposeFlight", flightbookingapi.ProposeFlight.URL())
graph.SetEndpoint("AwaitDecision", flightbookingapi.AwaitDecision.URL())
graph.SetEndpoint("ChooseSeat", flightbookingapi.ChooseSeat.URL())
graph.SetEndpoint("ConfirmBooking", flightbookingapi.ConfirmBooking.URL())
graph.SetEndpoint("NoFlights", flightbookingapi.NoFlights.URL())
graph.AddTransition("SearchFlights", "ProposeFlight")
// First-match routing: exhausted ends the search, otherwise present the candidate for a decision.
graph.AddTransitionSwitch("ProposeFlight", "NoFlights", "exhausted")
graph.AddTransitionSwitch("ProposeFlight", "AwaitDecision", "true")
// Accept takes the normal edge to seat selection; keep-searching drives flow.Goto back to ProposeFlight.
graph.AddTransition("AwaitDecision", "ChooseSeat")
graph.AddTransitionGoto("AwaitDecision", "ProposeFlight")
graph.AddTransition("ChooseSeat", "ConfirmBooking")
graph.AddTransition("ConfirmBooking", workflow.END)
graph.AddTransition("NoFlights", workflow.END)
return graph, nil
}The seat agent is a separate one-node graph, ChooseSeatAgent, whose single PickSeat task runs the LLM loop. BookFlight’s ChooseSeat node invokes it as an isolated subgraph. Its source (CHOOSESEATAGENT.mmd):
%%{init: {'themeVariables': {'lineColor': '#32a7c1', 'edgeLabelBackground': '#e5f4f3', 'textColor': '#434343', 'titleColor': '#32a7c1', 'clusterTextColor': '#32a7c1'}}}%%
graph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
_title{{"choose-seat-agent"}}:::term --> _start
_start(( )):::term --> t0
t0["PickSeat"]:::task
t0 --> _end(( )):::termCode Walkthrough
Source: exampleservices/flightbooking/service.go.
AwaitDecision — the Interrupt/Resume Heart
AwaitDecision is the heart of the example. On first dispatch it calls flow.Interrupt with the proposed flight as the payload and returns, parking the flow in interrupted status. The Demo page reads that payload, presents the flight, and later calls foreman.Resume(flowKey, {accepted}). On re-entry the task reads the decision: accept takes the normal transition to ChooseSeat; keep-searching advances the flight index and drives flow.Goto("ProposeFlight") to loop back and propose the next candidate.
func (svc *Service) AwaitDecision(ctx context.Context, flow *workflow.Flow, currentFlight flightbookingapi.Flight, flightIndex int) (accepted bool, flightIndexOut int, err error) {
var resume struct {
Accepted bool `json:"accepted"`
}
yield, err := flow.Interrupt(map[string]any{"flight": currentFlight, "index": flightIndex}, &resume)
if err != nil {
return false, flightIndex, errors.Trace(err)
}
if yield {
// Parked, waiting for the demo page to Resume with the traveler's decision.
return false, flightIndex, nil
}
if resume.Accepted {
return true, flightIndex, nil
}
// Keep searching: advance to the next candidate and loop back to ProposeFlight.
flow.Goto("ProposeFlight")
return false, flightIndex + 1, nil
}Interrupt returns a yield signal the first time through, exactly like a subgraph parking — the task returns cleanly and the Foreman holds the flow until Resume supplies the decision, at which point it re-enters the same task with yield false and resume populated.
ProposeFlight — the Switch That Ends the Search
ProposeFlight selects the candidate at the current index, or reports the list exhausted when the index runs past the end. The AddTransitionSwitch on its exhausted output routes an empty result to NoFlights (no flights on the route, or every candidate declined) and otherwise presents the candidate for a decision.
func (svc *Service) ProposeFlight(ctx context.Context, flow *workflow.Flow, candidates []flightbookingapi.Flight, flightIndex int) (currentFlight flightbookingapi.Flight, exhausted bool, err error) {
if flightIndex < 0 || flightIndex >= len(candidates) {
return flightbookingapi.Flight{}, true, nil
}
return candidates[flightIndex], false, nil
}ChooseSeat — the Subgraph With a Fallback
ChooseSeat delegates to the ChooseSeatAgent child workflow through the generated subgraph client. If the seat agent errors — the usual cause being no configured LLM provider — it logs a warning and falls back to the first available seat, so the booking completes with or without a model:
func (svc *Service) ChooseSeat(ctx context.Context, flow *workflow.Flow, seatPreference string, currentFlight flightbookingapi.Flight) (seat string, err error) {
seat, yield, err := flightbookingapi.NewSubgraph(flow).ChooseSeatAgent(ctx, seatPreference, currentFlight.Seats)
if yield {
return "", nil
}
if err != nil {
// The seat agent needs a real LLM provider. Without one, fall back to the first available seat so the
// human-in-the-loop booking still completes end-to-end; the LLM only refines this choice.
svc.LogWarn(ctx, "seat agent unavailable, assigning first available seat", "error", err)
if len(currentFlight.Seats) > 0 {
return currentFlight.Seats[0], nil
}
return "", nil
}
return seat, nil
}Only seatPreference and the flight’s Seats list cross into the child; only the chosen seat string crosses back. The child cannot see the rest of BookFlight’s state, which is the point of a subgraph boundary.
PickSeat — the Leaf LLM Task
PickSeat is ChooseSeatAgent’s one task. It runs ChatLoop as a subgraph with no tools, asks the model to pick exactly one seat label, and normalizes the reply against the available list:
func (svc *Service) PickSeat(ctx context.Context, flow *workflow.Flow, seatPreference string, availableSeats []string) (seat string, err error) {
items := []llmapi.Item{
llmapi.NewMessage("system", "You are a seat-assignment assistant. Choose exactly one seat from the available list that best matches the traveler's preference. Reply with only the seat label and nothing else.").AsItem(),
llmapi.NewMessage("user", fmt.Sprintf("Available seats: %s\nPreference: %s", strings.Join(availableSeats, ", "), seatPreference)).AsItem(),
}
result, _, yield, err := llmapi.NewSubgraph(flow).ChatLoop(ctx, llmapi.ProviderAny, llmapi.ModelDefault, items, nil, nil)
if yield {
return "", nil
}
if err != nil {
return "", errors.Trace(err)
}
answer, err := finalAnswer(result)
if err != nil {
return "", errors.Trace(err)
}
return matchSeat(answer, availableSeats), nil
}Demo — Why It Owns the Foreman Dependency
The Demo handler is the triggering UI: it foreman.Creates a new BookFlight flow, Awaits it to the first interrupt, presents the parked flight, and on the next POST calls foreman.Resume with the decision before awaiting again. A workflow provider must not import foremanapi to run its own graph — the Foreman is infrastructure in front of the workflow, not a downstream dependency. This microservice is the documented exception because it owns the triggering surface: the demo page is the UI that starts and resumes the flow, so its Create / Await / Resume calls are triggering code, the same self-contained-service shortcut the creditflow demo uses. In a real system the booking UI would be its own microservice and this one would expose only the graph and tasks.
See Also
- Yield and re-enter — the park-and-re-execute mechanic behind
flow.Interruptand the subgraph call this example is built on. - State — the function-call subgraph boundary that scopes what crosses into
ChooseSeatAgentand back. - Credit flow example — human-in-the-loop by branching, plus fan-out/fan-in and reducers.
- Weather example — the smallest single-node agent, and the LLM-only output that cannot degrade.
- Foreman core service — the engine that persists the interrupted flow and resumes it.
coreservices/llm— the orchestrator behind the seat-selection subgraph.