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 the BookFlight flow, 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. The PickSeat task runs its ChatLoop workflow 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 compositionSearchFlights, ProposeFlight, AwaitDecision, ChooseSeat, ConfirmBooking, and NoFlights are all nodes in the one BookFlight graph. They share its full state vocabulary (candidates, flight index, current flight, seat) and hand off through transitions.
  • SubgraphChooseSeat invokes the separate ChooseSeatAgent workflow as an isolated child via the generated flightbookingapi.NewSubgraph(flow).ChooseSeatAgent(...) client. Only the seat preference and available-seat list cross in, and only the chosen seat crosses back. ChooseSeatAgent’s own PickSeat task in turn runs llm.core’s ChatLoop as 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(( )):::term
func (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(( )):::term

Code 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 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.Interrupt and the subgraph call this example is built on.
  • State — the function-call subgraph boundary that scopes what crosses into ChooseSeatAgent and 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.