How to add a human approval step to an AI agent
Some actions need a person to say yes first: a refund, an email to a customer, a change in production. A durable run in mcpd can stop before the action, wait days for the decision, and continue from the same step.
Last updated
When you finish, your agent drafts an action, the run waits for a reviewer with no process kept alive, and the action runs only if the reviewer approves. A rejection or a timeout skips it. You need a running mcpd daemon and the mcpd SDK for TypeScript or Python.
1. Define the workflow with a wait
A workflow is a definition that mcpd stores and walks. Three steps: the agent
drafts, a wait_event step holds the run for the review, and the
action runs under a guard. The event's payload becomes the wait step's output,
which is what the guard reads.
the workflow
import { Mcpd, Worker, when, workflow } from '@mcpd/sdk'
const mcpd = new Mcpd({ project: 'support' })
const refunds = workflow({ id: 'refund_request' })
// Your agent drafts the action. Its model calls are recorded under this step.
.agent('draft', async (ctx, input: { ticket: string; amountUsd: number }) => ({
amountUsd: input.amountUsd,
reason: 'item arrived damaged',
}))
// The run stops here until an event named refund_reviewed arrives with this
// ticket's key, or a day passes. No process is held open while it waits.
.waitFor('approval', {
event: 'refund_reviewed',
key: '${input.ticket}',
timeoutMs: 24 * 3_600_000,
onTimeout: 'continue',
})
// Runs only when the reviewer approved. Skipped on a rejection or a timeout.
.step('issue_refund', async (ctx, input: { amountUsd: number }) => await issueRefund(input.amountUsd), {
input: { amountUsd: '${steps.draft.output.amountUsd}' },
when: when('steps.approval.output.approved', 'eq', true),
effect: 'at_most_once',
})
.output({ refund: '${steps.issue_refund.output}', review: '${steps.approval.output}' }) from mcpd import Mcpd, Worker, when, workflow
mcpd = Mcpd(project="support")
refunds = workflow("refund_request")
# Your agent drafts the action. Its model calls are recorded under this step.
@refunds.agent("draft")
def draft(ctx, input, prior):
return {"amount_usd": input["amount_usd"], "reason": "item arrived damaged"}
# The run stops here until an event named refund_reviewed arrives with this
# ticket's key, or a day passes. No process is held open while it waits.
refunds.wait_for("approval", event="refund_reviewed", key="${input.ticket}",
timeout_ms=24 * 3_600_000, on_timeout="continue")
# Runs only when the reviewer approved. Skipped on a rejection or a timeout.
@refunds.step("issue_refund", input={"amount_usd": "${steps.draft.output.amount_usd}"},
when=when("steps.approval.output.approved", "eq", True),
effect="at_most_once")
def issue_refund_step(ctx, input, prior):
return issue_refund(input["amount_usd"])
refunds.output({"refund": "${steps.issue_refund.output}", "review": "${steps.approval.output}"}) | Wait option | What it does |
|---|---|
event | The event name the run waits for. |
key | A template rendered per run, so one review answers one ticket and no other. |
timeoutMs | How long to wait. The ceiling is 3,650 days. |
onTimeout | fail (the default) ends the run in error. continue goes on with no output, so the guard skips the action. |
effect: 'at_most_once' is for a step with an effect outside the
run. If a worker loses the step after it started, the attempt fails rather
than runs again, so the refund is not paid twice.
Workflows explains effects and
retries.
2. Serve the steps with a worker
The draft and the refund run in your process. A worker registers the definition, claims those steps when they are due, and reports each result. The wait itself is the daemon's: no worker is busy while it waits.
the worker
const worker = new Worker(mcpd, { concurrency: 8 }).register(refunds)
// Store the definition now, so a run can start before the first claim.
await worker.registerDefinitions()
// Serve the draft and issue_refund steps for as long as the process lives.
void worker.serve() import threading
worker = Worker(mcpd, concurrency=8).register(refunds)
# Store the definition now, so a run can start before the first claim.
worker.register_definitions()
# Serve the draft and issue_refund steps on a thread of their own.
threading.Thread(target=worker.serve, daemon=True).start() 3. Start a run
Start one run per request, from anywhere that holds an execute key.
start
const run = await mcpd.runs.start({
definitionId: 'refund_request',
input: { ticket: 'T-1042', amountUsd: 42 },
}) run = mcpd.runs.start(refunds, input={"ticket": "T-1042", "amount_usd": 42}) - classify task completed
- draft agent attempt 2
- approval wait_event completed
- send task completed
Journal
- 00:00 Run started
- 00:03 classify completed
- 00:04 draft claimed by worker-a
- 00:07 worker-a stopped mid-step
- 00:10 Lease on draft expired
- 00:10 draft claimed by worker-b, attempt 2
- 00:14 draft completed
- 00:15 Waiting for approval, no process alive
- 00:21 Event approval arrived
- 00:24 send completed
- 00:24 Run completed
The run's status stays running while it waits. Its event log gets
a run_waiting entry, and a run_resumed entry when
the answer arrives. Your workers can restart, scale to zero or be redeployed
in the meantime.
4. Send the reviewer's decision
Your review screen publishes the event with the ticket as its key. Send your
own event_id: a second click or a retried webhook with the same id
is answered duplicate and changes nothing.
approve or reject
# From your review screen: the reviewer approved ticket T-1042.
curl -s -X POST http://localhost:4317/v1/api/events \
-H 'content-type: application/json' \
-H "authorization: Bearer $MCPD_EXECUTE_KEY" \
-d '{"project": "support", "event": "refund_reviewed", "key": "T-1042",
"payload": {"approved": true, "by": "ops@example.com"},
"event_id": "review-T-1042"}'
# → {"outcome":"delivered","event_id":"review-T-1042"} await mcpd.events.publish({
event: 'refund_reviewed',
key: 'T-1042',
payload: { approved: false, by: 'ops@example.com', note: 'outside policy' },
eventId: 'review-T-1042',
}) mcpd.events.publish("refund_reviewed", "T-1042",
payload={"approved": False, "by": "ops@example.com", "note": "outside policy"},
event_id="review-T-1042") | Outcome | Meaning |
|---|---|
delivered | A run was waiting. It took the event and moved on in the same transaction. |
parked | No run was waiting yet. mcpd holds the event for 24 hours for a wait that opens later. |
duplicate | That event_id was seen before. Nothing was written. |
To answer one run by its id instead of by key, post
event, payload and event_id to
POST /v1/api/runs/{run_id}/signal.
5. Check what happened
The run's output carries the decision and the result. With the approval it is
{"refund": {...}, "review": {"approved": true, "by": "ops@example.com"}}.
After a rejection, refund is null and the journal
shows issue_refund as skipped. After a timeout, both are
null. Read a run with mcpd.runs.get(runId), or open
Workflows in the interface. Each step is also a span in the
run's trace, so the draft's model calls are in the waterfall; see
Runs.
Troubleshooting
| What you see | Why, and what to do |
|---|---|
404 definition refund_request not found when you start a run |
The definition is not stored yet. A worker stores it when it starts serving, which can be after
your first run starts. Call registerDefinitions()
(register_definitions()) first, as in step 2.
|
The event comes back parked and the run keeps waiting | The name, key or project does not match the run's wait. The key is the rendered template, here the ticket id. |
The run failed at approval | The wait timed out and onTimeout was fail, the default. |
401 or 403 on start or publish |
Starting runs and publishing events need the execute scope (--execute-key), and the
worker needs the worker scope (--worker-key). With neither set, the daemon accepts
its write keys for both.
|
Related guides
- How to send LLM alerts to Slack
- How to collect thumbs-up and thumbs-down feedback on LLM answers
- How to read agent traces in Claude Code and Cursor
mcpd is free and open source. Start free at mcpd.sh , or run the daemon yourself.