Introduction
Your agent drafted a reply to an annoyed customer. It's good. It's probably fine. You'd still like a person to read it before it goes out, and that person is in meetings until three.
Putting a human in the loop means writing a wait that can last minutes or days, and the process doing the waiting will probably restart at least once before anyone clicks Approve. In this post you'll build the whole human-in-the-loop workflow with Dapr Workflow in Python, and see what happens when the app restarts halfway through the wait. The code is tested with Dapr 1.19.
The workflow
From Python SDK 1.19, workflow support ships inside the core dapr package, so install it with the workflow extra instead of the separate dapr-ext-workflow package:
pip install "dapr[workflow]"
Here's the approval workflow. The agent drafts a reply, asks a person to review it, and then waits:
from datetime import timedelta
import dapr.ext.workflow as wf
wfr = wf.WorkflowRuntime()
@wfr.workflow(name="reply_with_approval")
def reply_with_approval(ctx: wf.DaprWorkflowContext, ticket: dict):
draft = yield ctx.call_activity(draft_reply, input=ticket)
yield ctx.call_activity(request_review,
input={"id": ctx.instance_id, "draft": draft})
approval = ctx.wait_for_external_event("approval")
deadline = ctx.create_timer(timedelta(hours=24))
winner = yield wf.when_any([approval, deadline])
if winner == deadline:
yield ctx.call_activity(escalate, input=ticket)
return "escalated"
decision = approval.get_result()
if not decision.get("approved"):
return "rejected"
text = decision.get("text") or draft
yield ctx.call_activity(send_reply, input={"to": ticket["email"], "text": text})
return "sent"
@wfr.activity(name="draft_reply")
def draft_reply(ctx, ticket): ... # your LLM call
@wfr.activity(name="request_review")
def request_review(ctx, req): ... # Slack message, email, admin page
@wfr.activity(name="send_reply")
def send_reply(ctx, msg): ...
@wfr.activity(name="escalate")
def escalate(ctx, ticket): ...
The wait
wait_for_external_event("approval") returns a task that completes when someone raises an event called approval on this workflow instance. create_timer creates a durable timer that fires in 24 hours. when_any races the two and returns whichever task finishes first, and that decides the branch. This is the external system interaction pattern from the Dapr docs, which uses the same 24-hour race for a purchase order approval.
If you only need a deadline and no separate branch, the 1.19 SDK also accepts wait_for_external_event("approval", timeout=timedelta(hours=24)), which fails with a TimeoutError when the time runs out. The race keeps both outcomes visible as plain if branches, which reads better when the timeout path does real work.
The approver's side
client = wf.DaprWorkflowClient()
client.raise_workflow_event(instance_id, "approval",
data={"approved": True, "text": edited_text})
Put that behind a Slack button, a link in an email, or an internal page. The approver can send back an edited draft, and the workflow sends their version instead of the agent's. If they approve without a text field, the agent's draft goes out.
The timing is forgiving. Event names are case-insensitive, and an event raised before the workflow reaches wait_for_external_event is saved in the history and delivered when the workflow gets there. So a reviewer who clicks within a second of the Slack message arriving doesn't lose their click.
What happens if the app restarts at hour five
The workflow isn't holding a thread while it waits. When it yields on when_any, it unloads itself from memory. The draft, the review request and the timer's fire time are all recorded in the instance's history. When the app comes back, the runtime replays that history. draft_reply already completed, so it returns its stored result instead of running again. You don't get a second LLM call, and the reviewer doesn't get a second draft to be confused by. The workflow is back to waiting on the same event with the same deadline.
I tested this locally: started the workflow, stopped the app while it waited, started it again and raised the event. The reply went out, and draft_reply and request_review had each run exactly once.
What this doesn't do for you
- The approval UI is your job. The workflow gives you an instance ID and an event name; how a person gets from a notification to that event is up to you.
- You decide what a timeout means. Here it escalates, and for some actions it should reject outright.
- Late clicks go nowhere. Once the workflow has escalated and completed, an
approvalevent for that instance is silently discarded, so your UI should check the instance status and tell the approver they missed the window. - Treat the edited text in the event like any other user input, because it is.
- Make
send_replyidempotent. Activities run at least once, so a crash right after the email goes out can send it again. Key the send on the instance ID.
Summary
A human-in-the-loop step in Dapr Workflow is a wait_for_external_event raced against a durable timer, plus one raise_workflow_event call on the approver's side. The wait costs nothing while nobody answers, and a restart replays the history instead of redoing the LLM call. When a workflow is waiting on an approval that will never arrive, someone has to find it and unblock it. The workflow operations write-up covers how that works in Catalyst, including raising the event by hand. The free Dapr Workflow course covers external events and timers in more depth.
Pick one action your agent takes today without asking anyone, and put it behind this wait.
Top comments (0)