Long or sensitive processes need to survive restarts and let a person approve key decisions.
Persisting flows. Add @persist() above a flow class (or a single step) and CrewAI saves the state after each step, by default in SQLite (a small database stored as a single file on your disk). Every run has a state id. To continue a run later, start the flow with that id:
flow.kickoff(inputs={"id": saved_id})The flow reloads the saved state, so a crash or deploy does not lose work. To start a new run from an old state (a fork with a new id), use kickoff(restore_from_state_id=saved_id).
Human input in a crew. Task(..., human_input=True) makes the agent show its answer in the terminal and wait for your feedback before finishing. It is simple, but it only works when someone is at the console.
Human feedback in flows. The @human_feedback decorator pauses after a step and asks a person to review its output:
messageis the question shown to the reviewer.emit=["approved", "rejected"]lists possible outcomes. An LLM maps the person's free-text reply to one of them, and the matching@listen("approved")step runs.default_outcomeis used when the reply is empty.
The reply is available in the step's result and in self.last_human_feedback.
Design tips: put human checkpoints only at high-impact points such as sending emails, spending money or publishing. Persist state before them, because a reviewer may answer hours later. In deployed flows on CrewAI AMP, the API has a resume endpoint for continuing paused runs.
Example
from pydantic import BaseModel
from crewai.flow import Flow, human_feedback, listen, persist, start
class EmailState(BaseModel):
draft: str = ""
@persist() # save state after each step (SQLite)
class EmailFlow(Flow[EmailState]):
@start()
@human_feedback(message="Send this email to the customer?",
emit=["approved", "rejected"], llm="openai/gpt-4o-mini",
default_outcome="rejected")
def draft_email(self):
self.state.draft = "Hi Sam, your refund was approved and sent today."
return self.state.draft
@listen("approved")
def send(self):
return f"sent: {self.state.draft}"
@listen("rejected")
def hold(self):
return f"held for edits ({self.last_human_feedback.feedback})"
flow = EmailFlow()
print(flow.kickoff()) # asks in the terminal, then sends or holds
print(flow.state.id) # keep this id to resume: kickoff(inputs={"id": ...})Remember
@persist()saves flow state; resume withkickoff(inputs={"id": ...})human_input=Trueon a task asks for console feedback@human_feedback(message, emit=[...])routes on a person's decision
Watch out for
- Adding human checkpoints to every step, which makes the process slow and trains reviewers to approve without reading.