Skip to content

Chat flows by example

examples/chat_flows.py is a scripted city help desk (no model, no API key): one agent picks a Flow per question, one per pattern. Run python examples/chat_flows.py, or replay it here.

Open the demo full screen. The Graph lights up node by node.

The agent

chat runs the picked flow in the turn's run, so tool calls show as cards; session.state keeps the museum for later turns. Each flow ends in reply, where a stand-in model writes the answer, so the Cost tab has tokens and dollars.

def pick_flow(text: str) -> Flow[Any]:
    """The flow for a question, by its words."""
    words = text.lower()
    for keys, flow in (
        (("book",), booking_flow),
        (("compare", "prices at"), compare_flow),
        (("park", "playground"), park_flow),
        (("plan",), day_flow),
    ):
        if any(key in words for key in keys):
            return flow
    return ask_flow


async def chat(message: ChatMessage, session: ChatSession) -> AsyncIterator[str]:
    """Runs the question's flow in this turn's run, then streams the answer in chunks."""
    flow = pick_flow(message.text)
    start: dict[str, Any] = {"question": message.text}
    if flow is booking_flow and session.state.get("place"):
        start["place"] = session.state["place"]  # the museum asked about earlier in this chat
    runner = FlowRunner(session.executor, model=WRITER, trace_state=True)
    result = await runner.run(flow, start, ctx=session.context())
    place = getattr(result.state, "place", "")
    if place:
        session.state["place"] = place  # remembered for later turns; editable in the page
    session.state["questions"] = session.state.get("questions", 0) + 1
    words = result.state.answer.split(" ")
    for at in range(0, len(words), 3):
        yield " ".join(words[at : at + 3]) + (" " if at + 3 < len(words) else "")
        await asyncio.sleep(0.05)


async def write(request: LLMRequest) -> dict[str, Any]:
    """Stands in for a model: it returns the draft it is given, with made-up token counts."""
    draft = request.messages[-1].content
    return {"text": draft, "usage": {"input_tokens": 60 + len(draft) // 4, "output_tokens": 40}}


WRITER = CallableModel(write, provider="local", model="writer")
PRICES = Prices.empty().merged(
    {"version": "demo", "models": {"writer": {"provider": "local", "input": 1.0, "output": 4.0}}}
)
Node = Callable[[Any, ToolContext], Awaitable[dict[str, Any]]]


def reply(draft: Callable[[Any], str]) -> Node:
    """The last node of every flow: the writer model turns the flow's draft into the answer."""

    async def write_answer(state: Any, ctx: ToolContext) -> dict[str, Any]:
        model = node_model(ctx)
        if model is None:
            raise ConfigError("the reply node needs FlowRunner(model=...)")
        message = Message(role="user", content=draft(state))
        return {"answer": (await model.generate(LLMRequest(messages=(message,)))).text}

    return write_answer

1. Router: "Is the City Museum open on Monday?"

classify reads the question. A Route sends it to one branch: hours, prices or help.

flowchart TD
  aa_start((start)) --> n_classify
  n_classify["classify"]
  n_hours(["hours<br/>tool: opening_hours"])
  n_prices(["prices<br/>tool: ticket_price"])
  n_help["help"]
  n_reply["reply"]
  n_classify --> n_hours
  n_classify --> n_prices
  n_classify -->|"other"| n_help
  n_hours --> n_reply
  n_prices --> n_reply
  n_help --> n_reply
  n_reply --> aa_end((end))
  classDef pause stroke-dasharray: 5 5
class Ask(BaseModel):
    question: str
    place: str = ""
    day: str = ""
    topic: str = ""
    facts: dict[str, Any] = Field(default_factory=dict)
    answer: str = ""


def classify(state: Ask) -> dict[str, Any]:
    text = state.question.lower()
    place = next((name for name in MUSEUMS if name.lower() in text), "City Museum")
    day = next((d for d in DAYS if d.lower() in text), "Saturday")
    topic = "hours" if "open" in text else "prices" if "price" in text else "other"
    return {"topic": topic, "place": place, "day": day}


def ask_draft(s: Ask) -> str:
    f = s.facts
    if s.topic == "hours" and f["open"]:
        return f"Yes: the **{s.place}** is open on {s.day}, {f['hours']}."
    if s.topic == "hours":
        return f"No: the **{s.place}** is closed on {s.day}s. Other days: {f['hours']}."
    if s.topic == "prices":
        return f"An adult ticket at the {s.place} costs **{f['adult_eur']:.2f} EUR**."
    return "I can check hours and prices, compare museums, book tickets and find parks."


ask_flow = (
    Flow("ask", state=Ask)
    .node("classify", classify)
    .node("hours", Call("opening_hours", lambda s: {"place": s.place, "day": s.day}, "facts"))
    .node("prices", Call("ticket_price", lambda s: {"place": s.place}, "facts"))
    .node("help", lambda s: {"facts": {}})
    .node("reply", reply(ask_draft))
    .route(
        "classify",
        Route(lambda s: s.topic, {"hours": "hours", "prices": "prices", "other": "help"}),
    )
    .edge("hours", "reply")
    .edge("prices", "reply")
    .edge("help", "reply")
    .end("reply")
)

2. Fan-out, fan-in: "Compare ticket prices at three museums"

prices is a Map: three ticket_price calls run at once. combine is a Join that waits for them all, then sorts the prices into a table.

flowchart TD
  aa_start((start)) --> n_plan
  n_plan["plan"]
  n_prices[["prices<br/>fan-out over lookups<br/>tool: ticket_price"]]
  n_combine{{"combine<br/>fan-in: prices"}}
  n_reply["reply"]
  n_plan --> n_prices
  n_prices --> n_combine
  n_combine --> n_reply
  n_reply --> aa_end((end))
  classDef pause stroke-dasharray: 5 5
class Compare(BaseModel):
    question: str
    lookups: list[dict[str, Any]] = Field(default_factory=list)
    prices: Annotated[list[dict[str, Any]], Merge.append] = Field(default_factory=list)
    table: str = ""
    answer: str = ""


def pick_museums(state: Compare) -> dict[str, Any]:
    return {"lookups": [{"place": name} for name in list(MUSEUMS)[:3]]}


def cheapest_first(state: Compare) -> dict[str, Any]:
    rows = sorted(state.prices, key=lambda p: p["adult_eur"])
    lines = ["| Museum | Adult |", "|---|---|"]
    lines += [f"| {p['place']} | {p['adult_eur']:.2f} EUR |" for p in rows]
    return {"table": "\n".join(lines)}


compare_flow = (
    Flow("compare_prices", state=Compare)
    .node("plan", pick_museums)
    .node("prices", Map("lookups", tool="ticket_price", into="prices"), concurrency=3)
    .node("combine", Join(["prices"], fn=cheapest_first))
    .node("reply", reply(lambda s: f"Cheapest first:\n\n{s.table}"))
    .edge("plan", "prices")
    .edge("prices", "combine")
    .edge("combine", "reply")
    .end("reply")
)

3. Approval: "Book two tickets for Saturday"

book_tickets has confirm=True, so the call waits for Approve or Deny in its card. A decision is used once. In the recording the demo approves it; a denial fails the turn.

flowchart TD
  aa_start((start)) --> n_details
  n_details["details"]
  n_price(["price<br/>tool: ticket_price"])
  n_book(["book<br/>tool: book_tickets"])
  n_reply["reply"]
  n_details --> n_price
  n_price --> n_book
  n_book --> n_reply
  n_reply --> aa_end((end))
  classDef pause stroke-dasharray: 5 5
class Booking(BaseModel):
    question: str
    place: str = "City Museum"
    day: str = "Saturday"
    people: int = 2
    price: dict[str, Any] = Field(default_factory=dict)
    booking: dict[str, Any] = Field(default_factory=dict)
    answer: str = ""


def booking_details(state: Booking) -> dict[str, Any]:
    text = state.question.lower()
    day = next((d for d in DAYS if d.lower() in text), state.day)
    people = next((n for word, n in (("one", 1), ("two", 2), ("three", 3)) if word in text), 2)
    return {"day": day, "people": people}


def booking_draft(s: Booking) -> str:
    total = s.price["adult_eur"] * s.people
    b = s.booking
    trip = f"{b['place']}, {b['day']}"
    return f"Booked: **{b['people']} tickets**, {trip} ({total:.2f} EUR, ref `{b['ref']}`)."


booking_flow = (
    Flow("booking", state=Booking)
    .node("details", booking_details)
    .node("price", Call("ticket_price", lambda s: {"place": s.place}, "price"))
    .node(
        "book",
        Call(
            "book_tickets",
            lambda s: {"place": s.place, "day": s.day, "people": s.people},
            "booking",
        ),
    )
    .node("reply", reply(booking_draft))
    .edge("details", "price")
    .edge("price", "book")
    .edge("book", "reply")
    .end("reply")
)

4. Loop: "Find a park with a playground near the river"

search looks within 0.5 km. check doubles the distance when no park has a playground, and the route goes back to search, at most 3 times. Here it widens twice.

flowchart TD
  aa_start((start)) --> n_search
  n_search(["search<br/>tool: find_parks"])
  n_check["check"]
  n_reply["reply"]
  n_search --> n_check
  n_check -.->|"widen (max 3)"| n_search
  n_check -->|"done"| n_reply
  n_reply --> aa_end((end))
  classDef pause stroke-dasharray: 5 5
class ParkSearch(BaseModel):
    question: str
    within_km: float = 0.5
    found: list[dict[str, Any]] = Field(default_factory=list)
    matches: list[str] = Field(default_factory=list)
    answer: str = ""


def enough_parks(state: ParkSearch) -> dict[str, Any]:
    matches = [p["name"] for p in state.found if p["playground"]]
    return {"matches": matches} if matches else {"within_km": state.within_km * 2}


def park_draft(s: ParkSearch) -> str:
    if not s.matches:
        return "No park with a playground by the river is close enough."
    return f"**{s.matches[0]}** has a playground by the river, within {s.within_km:g} km."


park_flow = (
    Flow("park_search", state=ParkSearch)
    .node(
        "search", Call("find_parks", lambda s: {"near": "river", "within_km": s.within_km}, "found")
    )
    .node("check", enough_parks)
    .node("reply", reply(park_draft))
    .edge("search", "check")
    .route(
        "check",
        Route(lambda s: "done" if s.matches else "widen", {"widen": "search", "done": "reply"}),
        max_loops=3,
        on_exhausted="reply",
    )
    .end("reply")
)

5. All together: "Plan a rainy day for a family"

The forecast routes to indoor plans. check fans out over two museums and shortlist joins them. With fewer than two family picks, it loops back for the next two.

flowchart TD
  aa_start((start)) --> n_forecast
  n_forecast(["forecast<br/>tool: forecast"])
  n_outdoor["outdoor"]
  n_candidates["candidates"]
  n_check[["check<br/>fan-out over lookups<br/>tool: family_check"]]
  n_shortlist{{"shortlist<br/>fan-in: check"}}
  n_reply["reply"]
  n_forecast -->|"rain"| n_candidates
  n_forecast -->|"sun"| n_outdoor
  n_outdoor --> n_reply
  n_candidates --> n_check
  n_check --> n_shortlist
  n_shortlist -.->|"more (max 2)"| n_candidates
  n_shortlist -->|"done"| n_reply
  n_reply --> aa_end((end))
  classDef pause stroke-dasharray: 5 5
INDOOR = (("Harbour Gallery", "City Museum"), ("Science Hall", "Clock Tower"))


class DayPlan(BaseModel):
    question: str
    weather: dict[str, Any] = Field(default_factory=dict)
    round: int = 0
    lookups: list[dict[str, Any]] = Field(default_factory=list)
    checked: Annotated[list[dict[str, Any]], Merge.append] = Field(default_factory=list)
    picks: list[str] = Field(default_factory=list)
    answer: str = ""


def next_places(state: DayPlan) -> dict[str, Any]:
    batch = INDOOR[min(state.round, len(INDOOR) - 1)]
    return {"lookups": [{"place": name} for name in batch], "round": state.round + 1}


def shortlist(state: DayPlan) -> dict[str, Any]:
    return {"picks": [c["place"] for c in state.checked if c["family"]]}


def day_draft(s: DayPlan) -> str:
    if not s.picks:
        return f"It will be {s.weather['sky']}, up to {s.weather['high_c']} C: a day in the parks."
    stops = " then ".join(f"**{place}**" for place in s.picks)
    return f"Rain all day (up to {s.weather['high_c']} C), so stay inside: {stops}."


day_flow = (
    Flow("day_plan", state=DayPlan)
    .node("forecast", Call("forecast", {"day": "Sunday"}, "weather"))
    .node("outdoor", lambda s: {"picks": []})
    .node("candidates", next_places)
    .node("check", Map("lookups", tool="family_check", into="checked"), concurrency=2)
    .node("shortlist", Join(["check"], fn=shortlist))
    .node("reply", reply(day_draft))
    .route("forecast", Route(lambda s: s.weather["sky"], {"rain": "candidates", "sun": "outdoor"}))
    .edge("candidates", "check")
    .edge("check", "shortlist")
    .route(
        "shortlist",
        Route(
            lambda s: "done" if len(s.picks) >= 2 else "more",
            {"more": "candidates", "done": "reply"},
        ),
        max_loops=2,
        on_exhausted="reply",
    )
    .edge("outdoor", "reply")
    .end("reply")
)

--check runs all five questions offline and prints the answers. See also Chat with your agent and Navigate the graph.