Steering Agents by Conversation: Chat as a Control Surface
CrewAI turns a chat thread into an agent control surface — routing turns, pausing for human feedback, and resuming runs mid-flight.
Published on • October 7, 2026
AI Assistant

Most agent frameworks treat chat as a frontend afterthought: you send a message, the agent runs to completion, you read the result. The interesting shift is the inversion — treating the conversation itself as the control surface for the agent runtime. Instead of “invoke agent, wait, read output,” each user line becomes a routed event that can start work, redirect a running flow, approve a step, or terminate the session.
CrewAI has built this model directly into its Flow runtime. Here’s how the pieces fit together, based on the CrewAI docs.
The Core Model: One Turn, One Flow Run
In CrewAI’s conversational flows, each user message is a new flow run sharing the same session id:
handle_turn(message, session_id=...) # one-turn wrapper
→ kickoff(inputs={"id": ...})
→ state.id
The available turn APIs form a ladder from simple to raw:
| API | Purpose |
|---|---|
handle_turn(message, session_id=...) | One-turn wrapper for REST/WebSocket/UIs |
stream_turn(...) | Ordered runtime frames for streaming UIs |
chat() | Local terminal REPL |
kickoff(inputs={...}) | Raw flow invocation |
ask() | Blocking prompt inside one step (wizard/clarification) |
@human_feedback | Approve/reject a step output, not the next chat line |
Note the last two are different tools: ask() pauses for input within a step, while @human_feedback gates a completed step’s output before the flow continues.
One sharp edge: Flow.kickoff() does not accept user_message= or session_id= — those belong to handle_turn(). Calling kickoff() directly gives you a raw run without conversation semantics.
Opting In: ConversationConfig
Conversational behavior is opt-in via a class decorator:
@ConversationConfig(
system_prompt=...,
llm=...,
router=RouterConfig(...),
intent_llm=...,
default_intents=[...],
visible_agent_outputs=[...],
)
class MyFlow(Flow):
...
Or simply set conversational = True for defaults.
Routing is where “chat as control surface” becomes concrete. The built-in route_conversation start/router classifies each incoming line and dispatches to a handler:
@listen("INTERNET_SEARCH")
def search_handler(self):
"""Search the web for current information."""
...
The handler’s docstring becomes the router’s route description — the LLM uses it to decide where a user’s message should go. Built-in routes include converse (default LLM chat), end, and the deprecated answer_from_history. You can override routing with route_turn() or classify explicitly with classify_intent(text, outcomes, llm=...).
State: ConversationState
Each session carries a ConversationState holding messages, current_user_message, ended, events, and agent_threads. Helpers manage the transcript:
self.append_assistant_message(text)
self.append_agent_result(agent_name, result, visibility="private")
self.conversation_messages
self.receive_user_message(text)
self.finalize_session_traces()
The visibility="private" argument is notable: agent results can be recorded for trace/debug purposes without surfacing them to the user’s visible transcript. Pair with @persist for durable sessions.
Streaming works through stream_turn():
stream = flow.stream_turn("Where is my order?", session_id=sid)
for frame in stream:
if frame.channel == "llm" and frame.type == "llm_stream_chunk":
render(frame)
result = stream.result
Interrupting a Running Agent
Steering means pausing work, not just starting it. CrewAI offers two human-in-the-loop mechanisms:
1. The @human_feedback decorator (CrewAI 1.8.0+, local/console, synchronous).
@human_feedback(
message="Approve this draft?",
emit=("approve", "revise"),
llm=collaboration_llm, # required when emit is set
default_outcome="revise",
learn=True,
learn_limit=5,
)
def review_step(self):
return draft
When emit is provided, an LLM collapses free-form feedback into one of the declared outcomes via structured output — so “make it shorter and less formal” becomes revise with the text preserved as context. The result arrives as a HumanFeedbackResult dataclass with output, feedback, outcome, timestamp, and metadata, accessible via self.last_human_feedback and self.human_feedback_history.
The revision loop follows naturally:
@listen(or_("generate_draft", "needs_revision"))
def revise(self):
...
The flow engine exempts routers from the “fire once” rule, so this pattern re-fires correctly.
2. Webhook-based pause/resume (Enterprise, async).
Configure a task with humanInputWebhook: {url, authentication: {...}}. On kickoff, the run pauses in a Pending Human Input state. Your service receives the execution ID, task ID, and task output, then:
POST /resume
{ "execution_id": "...", "task_id": "...",
"human_feedback": "...", "is_approve": false }
Negative feedback makes the crew retry the task with the feedback as added context; positive feedback proceeds. The REST surface is POST /kickoff, POST /resume, GET /status/{kickoff_id}.
3. Non-blocking programmatic pause. Implement HumanFeedbackProvider — raising HumanFeedbackPending makes kickoff() return (not throw) a pending object, auto-persisted via SQLiteFlowPersistence. Resume later with:
flow = MyFlow.from_pending(flow_id)
flow.resume(fb) # sync
await flow.resume_async(fb) # async — must be inside a running loop
Frontend Steering with AG-UI
For web UIs, the CopilotKit/AG-UI integration makes the pause visible. The frontend registers a tool via useHumanInTheLoop:
useHumanInTheLoop({
agentId,
name: "generate_task_steps",
parameters: z.object({ steps: z.array(z.string()) }),
render: ({ args, respond, status }) => (
<ApproveButtons onApprove={() => respond({ approved: true })} />
),
});
When the model calls that tool, the run halts at the tool call — status === "executing" means the agent is waiting on a human. respond(value) hands control back and the value lands as a tool result in the agent’s message state. On the flow side, bind tools=[*self.state.copilotkit.actions] and serve with add_crewai_flow_fastapi_endpoint(app, flow=..., path=...).
The Design Pattern
Underneath the APIs, the pattern generalizes beyond CrewAI:
- Each conversation turn is an event, not a monolithic run — state carries across turns via session id.
- Routing is explicit — an intent classifier (LLM or rules) maps a line to a handler, with handler docstrings as prompt material.
- Long work pauses via durable state — pending-execution records, not threads held open.
- Resume is a first-class API call —
POST /resume,flow.resume(), or a tool result — carrying the human’s decision back into the same execution.
That’s what “chat as a control surface” means in practice: the transcript is both the audit log and the joystick.
Further Reading
- CrewAI documentation
- CrewAI home
- CrewAI docs index:
https://docs.crewai.com/llms.txt