Skip to content
Blog

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:

APIPurpose
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_feedbackApprove/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:

  1. Each conversation turn is an event, not a monolithic run — state carries across turns via session id.
  2. Routing is explicit — an intent classifier (LLM or rules) maps a line to a handler, with handler docstrings as prompt material.
  3. Long work pauses via durable state — pending-execution records, not threads held open.
  4. 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