Skip to content
mcpd
no daemon

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}' })
Wait optionWhat it does
eventThe event name the run waits for.
keyA template rendered per run, so one review answers one ticket and no other.
timeoutMsHow long to wait. The ceiling is 3,650 days.
onTimeoutfail (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()

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 },
})
Durable run completed
  1. classify task completed
  2. draft agent attempt 2
  3. approval wait_event completed
  4. send task completed
worker-a exited
worker-b idle
mcpd holds the run

Journal

  1. 00:00 Run started
  2. 00:03 classify completed
  3. 00:04 draft claimed by worker-a
  4. 00:07 worker-a stopped mid-step
  5. 00:10 Lease on draft expired
  6. 00:10 draft claimed by worker-b, attempt 2
  7. 00:14 draft completed
  8. 00:15 Waiting for approval, no process alive
  9. 00:21 Event approval arrived
  10. 00:24 send completed
  11. 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"}
OutcomeMeaning
deliveredA run was waiting. It took the event and moved on in the same transaction.
parkedNo run was waiting yet. mcpd holds the event for 24 hours for a wait that opens later.
duplicateThat 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 seeWhy, 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.

mcpd is free and open source. Start free at mcpd.sh , or run the daemon yourself.