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, structured turn streaming, and a localflow.chat() REPL.
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.
handle_turn(), stream_turn(), and chat() raise ValueError unless conversational mode is enabled. Applying @ConversationConfig(...) enables it automatically; otherwise set conversational = True.
Quick start
Streaming a turn
Usestream_turn() when a UI or runtime needs structured events for one chat turn. It returns a stream session with ordered frames for Flow routing, LLM chunks, tool activity, and conversation messages.
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 — user-defined
@startmethods (if any) →route_conversation(the built-in start/router) → the selected@listenhandler.route_conversationalso calls the overridableconversation_start()helper. - 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) when the visible reply is not the return value, or when you trim history. A public string return is also recorded as assistant and included in the @persist snapshot, so a fresh Flow instance restores it. The user line is already stored by handle_turn — do not append it again in handlers.
Configuration overview
Decorating aFlow subclass with ConversationConfig both attaches the chat defaults and enables conversational mode. See the full field reference below. Override pre-classification per turn with handle_turn(..., intents=..., intent_llm=...).
Lower-level ChatState helpers
ChatState, the legacy ConversationalConfig, and crewai.flow.conversation helpers are still importable for advanced orchestration, tests, or custom wrappers. They are separate from the ConversationState / ConversationConfig API and 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.
ConversationState stores messages as ConversationMessage objects and additionally provides current_user_message, ended, events, and agent_threads. Use conversation_messages when passing its canonical history to an LLM.
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 from crewai.flow.conversation for tests or custom orchestration. These helpers use the legacy ConversationalConfig shape; prepare_conversational_turn() also clears last_intent, unlike handle_turn(), which preserves it as router context.
Intent routing patterns
A. Pre-classify via ConversationConfig (simplest)
Set default_intents and intent_llm. Each handle_turn() pre-classifies the current message. A non-empty result returned by a custom route_turn() takes precedence; otherwise route_conversation uses the current turn’s classified intent.
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
Eachhandle_turn() completes one graph run, and the conversation continues with another handle_turn() using the same session_id. With the default deferred trace lifecycle, that run emits conversation_turn_completed, while FlowFinished is emitted once when finalize_session_traces() closes the session. @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
Opt into the conversational chat graph by setting conversational = True on a Flow subclass or applying @ConversationConfig(...). The base Flow then supplies route_conversation as the built-in start/router plus the converse_turn and end_conversation listeners. The deprecated answer_from_history_turn listener remains available for compatibility. The framework 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.
With no custom routes, turns fall through to
converse. With custom routes and a conversation/router LLM, the framework synthesizes a default RouterConfig; provide one explicitly only to customize its prompt, route list, descriptions, or fallback behavior. Setting default_intents uses the legacy pre-classification path instead.
If no conversation LLM is configured, the built-in converse_turn returns a configuration placeholder rather than generating an answer.
RouterConfig and the auto-built route catalog
RouterConfig.route_descriptions[label]— explicit override.Flow.builtin_route_descriptions[label]— framework-canned text forconverse,end, and the deprecatedanswer_from_historycompatibility route (phrased for the router LLM).- The method’s declared
description(used by declarative flows and DSL projections). - 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:
Naming handlers
The string in@listen("…") is a router route label (an event name), not the Python method name. Route labels and method completion events share one trigger namespace, so naming a handler the same as its route causes the handler to re-trigger itself in a loop.
Use a different method name — the docs examples use a handle_* prefix:
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 user-defined
@startmethods (if any), thenroute_conversationas the built-in start/router, then the chosen@listenhandler.route_conversationinvokes the overridableconversation_start()helper. - 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 and persists the updatedstate.messagesso@persistrestore includes the assistant turn.
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.
chat(defer_trace_finalization=True) temporarily enables the instance deferral flag for the REPL and restores its prior value on exit.
Customize the terminal behavior with injectable I/O:
handle_turn() directly.
Custom router behavior
To run side effects (event bus setup, telemetry) on every routing decision, overrideroute_turn:
route_turn. A falsy return does not invoke _route_with_config() from your override; routing falls through to this turn’s pre-classified intent, then the deprecated answer_from_history compatibility path when configured, and finally converse. A previous turn’s last_intent is available in router context but is never replayed as a fallback.
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).
Declaring a conversational flow in JSON/YAML
A declarative Flow can be conversational too. Add a top-levelconversational block and declare your own routes as methods that listen to a route label:
enabled defaults to true. Set enabled: false to keep the configuration while turning chat off. This also disables built-in method synthesis, so the declaration must provide a normal non-conversational graph.
Three things are supplied for you:
Declarative
llm, router.llm, and intent_llm fields accept either a model id or a configuration mapping such as {model: openai/gpt-4o-mini, max_tokens: 512}. The conversational block also supports default_intents, visible_agent_outputs, defer_trace_finalization, and the RouterConfig fields shown above. Deprecated answer_from_history_prompt / answer_from_history_llm declarations remain accepted for compatibility.
Run it from Python with the same turn APIs as a class-based conversational Flow:
Naming routes
Route labels and method names share one trigger namespace, so a handler must not be named after the route it listens to —create_video listening to create_video is rejected when the flow is built. Use a handle_* prefix.
What a declaration cannot express
crewai run opens the chat TUI for a declarative conversational flow — the same one a Python conversational Flow gets. A chat loop needs a terminal, so a headless run exits non-zero with guidance instead of running a single turn; drive it from Python there with handle_turn() or stream_turn(). A declarative method with a human_feedback: block (Python: @human_feedback) runs on a terminal REPL, because the runtime collects feedback with a blocking prompt the TUI cannot service. --inputs is not accepted for a conversational flow — each turn’s input is the message you type — and resuming a session by id is not wired into the CLI yet; use flow.handle_turn(message, session_id=...) from Python for that.
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 hides Rich console panels and suppresses method execution events. Flow start/finish events still emit, so the outer Flow lifecycle remains traceable, but individual method spans are omitted.
Conversational Flow trace lifecycle
The conversational Flow uses the same tracing lifecycle: defer_trace_finalization defaults to True, so each handle_turn() keeps the session trace open. Deferred turns also suppress per-turn flow_failed; on a turn error or session abort, finalize the session explicitly. This closes the batch with the session-level FlowFinished event rather than a per-turn FlowFailed event. Always 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
For conversational UIs, usestream_turn() and iterate its ordered StreamFrame objects:
stream = True makes kickoff() return a StreamSession. Do not set flow.stream = True when using handle_turn(); stream_turn() owns the conversational streaming lifecycle.
Imports
See also
- Mastering Flow State Management — persistence, Pydantic state,
@persist - Build Your First Flow — flow basics
