Overview
Conversational apps treat each user line as a new flow run with the same session id. CrewAI adds helpers for message history, optional intent routing, deferred tracing, UI bridges, and a localflow.chat() REPL for conversational flows.
Turn APIs
Useflow.handle_turn(message, session_id=...) for every user message from REST, WebSocket, tests, and custom UIs. Use flow.chat() when you want a local terminal chat loop for a conversational Flow.
Flow.kickoff() does not accept user_message= or session_id= keyword arguments. For conversational flows, handle_turn() stores the pending message and calls kickoff(inputs={"id": session_id}) internally after resetting per-turn execution state.
Quick start
Turn lifecycle
Eachhandle_turn runs this pipeline:
- Turn setup — stores the pending user message, resolves the session id, resets per-turn execution tracking, and calls
kickoff(inputs={"id": session_id}). - State restore — if
inputs["id"]exists and@persistis configured, loads the latest snapshot. FlowStarted— emitted on the first deferred session turn only.- Pending turn hydration — appends the user message to
state.messages, setscurrent_user_message/last_user_message, and optionally classifies whenintents/default_intents+intent_llmare set. - Graph execution —
conversation_start→route_conversation→ the selected@listenhandler. - End of run — per-turn
flow_finishedand trace finalization are skipped when deferral is enabled; nestedAgent.kickoff()/ crews do not close the parent batch either.
append_assistant_message(reply) so the next turn’s conversation_messages includes assistant text. The user line is already stored by handle_turn — do not append it again in handlers.
ConversationConfig (class-level defaults)
Decorate your conversational Flow subclass with ConversationConfig.
Override pre-classification per turn with
handle_turn(..., intents=..., intent_llm=...).
Lower-level ChatState helpers
ChatState, ConversationalConfig, and crewai.flow.conversation helpers are still importable for advanced orchestration, tests, or custom wrappers. They do not add user_message= or session_id= keyword arguments to Flow.kickoff().
ConversationalInputs is a TypedDict for conventional kickoff(inputs={...}) keys: id, user_message, last_intent.
Flow conversational API
handle_turn parameters
kickoff parameters
Flow.kickoff() accepts inputs, input_files, from_checkpoint, and restore_from_state_id. Pass inputs={"id": session_id} when you need raw flow execution, but use handle_turn() when the call represents a chat message.
Instance attributes
Methods and properties
Module helpers (crewai.flow.conversation)
Importable for tests or custom orchestration:
Intent routing patterns
A. Pre-classify via ConversationConfig (simplest)
Set default_intents and intent_llm. Each handle_turn() runs classification before routing; read self.state.last_intent in route_turn().
B. Classify inside route_turn (richer prompts)
Set default_intents=None so handle_turn() only appends the user message. In route_turn(), call classify_intent with a custom prompt or descriptions:
@listen("RESEARCH") (or similar) for steps that run Agent.kickoff() with tools — not bare LLM.call() — when you need web research or multi-step tool use.
When the flow finishes but the user keeps chatting
FlowFinished means this graph run completed. The conversation continues with another handle_turn() and the same session_id. @persist restores messages, flags, and context.
Persist pattern: prefer @persist on a single terminal step (for example finalize) rather than on the whole Flow class. Class-level persist saves after every method; load_state uses the latest row, which may be a mid-run snapshot (for example right after bootstrap) and miss handler updates from the same turn.
Do not use @human_feedback for follow-up chat lines unless a human must approve a specific step output before it is shown.
Conversational Flow (experimental)
Opt into the conversational chat graph by setting conversational = True on a Flow subclass. The base Flow then ships a built-in @start / @router / converse_turn / end_conversation graph, manages state.messages, can drive a router LLM, and keeps the trace batch open across turns. You write the custom routes; the framework owns the rest.
Use this when you want a multi-turn chat with a router and per-route handlers without wiring the lifecycle yourself. Use Flow[ChatState] (the lower-level pattern above) when you need full control.
Quick example
chat():
chat() wraps handle_turn() in a REPL, exits on exit / quit, skips blank lines by default, and calls finalize_session_traces() when the session ends.
ConversationConfig
Class decorator that attaches per-class chat defaults.
RouterConfig and the auto-built route catalog
RouterConfig.route_descriptions[label]— explicit override.Flow.builtin_route_descriptions[label]— framework-canned text forconverse,end,answer_from_history(phrased for the router LLM).- First non-empty line of the
@listen(label)handler’s docstring. - Empty (the route is listed without a description).
@listen("X") + a one-line docstring:
RouterConfig.prompt is for domain framing (assistant persona, business rules, voice). The route catalog is auto-built — don’t list routes in prompt; they’ll drift the moment you add a handler.
Built-in routes
You can override any of these by defining a same-named handler in your subclass.
handle_turn() semantics
flow.handle_turn(message) runs one turn:
- Resets per-execution tracking (
_completed_methods,_method_outputs) so the graph re-runs — without this, repeatedkickoffcalls on the same flow instance would short-circuit on turn 2+ becauseFlow.kickoff_asynctreatsinputs={"id": ...}as a checkpoint restore. - Appends the user message to
state.messages, setscurrent_user_message/last_user_message.last_intentis preserved from the prior turn so the router LLM can use it as a signal. - Runs
conversation_start→route_conversation→ the chosen@listenhandler. - The router stores its decision in
state.last_intent(visible to the next turn’s router context). - If your handler returned a string and didn’t already call
append_assistant_message,handle_turnappends it for you.
handle_turn() for chat messages. Calling kickoff(inputs={"id": ...}) directly runs the flow graph without applying the conversational turn wrapper.
chat() for local REPLs
flow.chat() is the batteries-included terminal wrapper around handle_turn():
- Prompts for a user message.
- Stops on
exit/quit,EOFError, orKeyboardInterrupt. - Calls
handle_turn(message, session_id=...). - Prints the assistant result.
- Finalizes deferred session traces in a
finallyblock.
handle_turn() directly.
Custom router behavior
To run side effects (event bus setup, telemetry) on every routing decision, overrideroute_turn:
route_turn; returning None falls back to _route_with_config(...).
append_assistant_message and append_agent_result
Inside a @listen(label) handler, choose:
self.append_assistant_message(text)— adds a user-visible assistant turn tostate.messages. The next turn’sconverse_turnsees it.self.append_agent_result(agent_name, result, visibility="private")— records a structured event instate.eventsand a thread instate.agent_threads[agent_name]. Public visibility also callsappend_assistant_messagefor you. Use private results for scratch work that shouldn’t pollute the canonical history.
ConversationConfig.visible_agent_outputs can promote specific agents’ private results to public globally ("all", or a list of agent names).
Tracing across turns
Withdefer_trace_finalization=True (default in ConversationConfig):
- One trace batch for the whole chat session.
flow_startedon the first turn only;flow_finishedonce infinalize_session_traces().- Per-turn
kickoffdoes not print “Trace batch finalized”. - Nested work (
Agent.kickoff(), crews, Exa tools) appends to the parent batch; innerAgentExecutorflows do not close the session batch early.
flow.chat() calls finalize_session_traces() for you. When you own the loop
with handle_turn(), call finalize_session_traces() when
the session ends.
suppress_flow_events=True only hides Rich console panels; trace and method events still emit for observability.
Conversational Flow trace lifecycle
The experimental conversational Flow uses the same tracing lifecycle: defer_trace_finalization defaults to True, so each handle_turn() keeps the session trace open. Always finalize at the end of the session — wrap your REPL/loop in try/finally and call flow.finalize_session_traces() on exit. Without it, the trace batch stays open and the final conversation may never export.
Streaming
Setstream = True on the Flow class. kickoff(...) will then emit assistant_delta (and related) events through the standard event bus.
Imports
See also
- Mastering Flow State Management — persistence, Pydantic state,
@persist - Build Your First Flow — flow basics
- Demo:
lib/crewai/runner_conversational_flow_simple.py— minimal REPL withRESEARCH+ Exa agent
