Skip to main content

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 local flow.chat() REPL for conversational flows.

Turn APIs

Use flow.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

Streaming a turn

Use stream_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.
For the full frame contract, channel list, and async API, see Streaming Runtime Contract.

Turn lifecycle

Each handle_turn runs this pipeline:
  1. Turn setup — stores the pending user message, resolves the session id, resets per-turn execution tracking, and calls kickoff(inputs={"id": session_id}).
  2. State restore — if inputs["id"] exists and @persist is configured, loads the latest snapshot.
  3. FlowStarted — emitted on the first deferred session turn only.
  4. Pending turn hydration — appends the user message to state.messages, sets current_user_message / last_user_message, and optionally classifies when intents / default_intents + intent_llm are set.
  5. Graph executionconversation_startroute_conversation → the selected @listen handler.
  6. End of run — per-turn flow_finished and trace finalization are skipped when deferral is enabled; nested Agent.kickoff() / crews do not close the parent batch either.
Handlers should call 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:
Use @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)

This is an experimental feature. The conversational Flow surface (conversational = True, handle_turn, ConversationConfig, RouterConfig, ConversationState, the built-in graph + helpers) lives under crewai.experimental and may change shape before it graduates. Pin your CrewAI version if you depend on specific behavior, and watch the changelog for breaking updates. Open issues / feedback welcome.
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

For a local terminal chat, use 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

The router prompt that gets sent to the LLM is built automatically. For each route the framework picks a description with this precedence:
  1. RouterConfig.route_descriptions[label] — explicit override.
  2. Flow.builtin_route_descriptions[label] — framework-canned text for converse, end, answer_from_history (phrased for the router LLM).
  3. First non-empty line of the @listen(label) handler’s docstring.
  4. Empty (the route is listed without a description).
So in practice, adding a new route is @listen("X") + a one-line docstring:
…and the router LLM sees:
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:
  1. Resets per-execution tracking (_completed_methods, _method_outputs) so the graph re-runs — without this, repeated kickoff calls on the same flow instance would short-circuit on turn 2+ because Flow.kickoff_async treats inputs={"id": ...} as a checkpoint restore.
  2. Appends the user message to state.messages, sets current_user_message / last_user_message. last_intent is preserved from the prior turn so the router LLM can use it as a signal.
  3. Runs conversation_startroute_conversation → the chosen @listen handler.
  4. The router stores its decision in state.last_intent (visible to the next turn’s router context).
  5. If your handler returned a string and didn’t already call append_assistant_message, handle_turn appends it for you.
Call 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():
It handles the common local loop:
  1. Prompts for a user message.
  2. Stops on exit / quit, EOFError, or KeyboardInterrupt.
  3. Calls handle_turn(message, session_id=...).
  4. Prints the assistant result.
  5. Finalizes deferred session traces in a finally block.
Customize the terminal behavior with injectable I/O:
For web apps, background workers, tests, and custom transports, keep using handle_turn() directly.

Custom router behavior

To run side effects (event bus setup, telemetry) on every routing decision, override route_turn:
To bypass the LLM router entirely and pick a route programmatically, return a string from 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 to state.messages. The next turn’s converse_turn sees it.
  • self.append_agent_result(agent_name, result, visibility="private") — records a structured event in state.events and a thread in state.agent_threads[agent_name]. Public visibility also calls append_assistant_message for 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

With defer_trace_finalization=True (default in ConversationConfig):
  • One trace batch for the whole chat session.
  • flow_started on the first turn only; flow_finished once in finalize_session_traces().
  • Per-turn kickoff does not print “Trace batch finalized”.
  • Nested work (Agent.kickoff(), crews, Exa tools) appends to the parent batch; inner AgentExecutor flows 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

Set stream = True on the Flow class. kickoff(...) will then emit assistant_delta (and related) events through the standard event bus.

Imports

See also