Approval Step
Require human approval before proceeding
The approval step pauses a workflow and waits for a human to review, approve, reject, or edit content before execution continues. This is the human-in-the-loop primitive that lets you gate AI-generated outputs on human judgment.
Schema
| Field | Type | Required | Description |
|---|---|---|---|
type | "approval" | Yes | Step type discriminator |
name | string (1-100 chars) | Yes | Unique step name within the workflow |
contentKey | string | Yes | Key in workflowState containing content to review |
prompt | string | No | Instructions shown to the reviewer |
timeoutMs | number | No | Maximum time to wait for a decision in milliseconds |
retry | RetryPolicy | No | Retry policy (default maxAttempts: 1) |
Note: The approval step defaults retry.maxAttempts to 1 (no retries), unlike other step types which default to 3.
Configuration Example
{
"type": "approval",
"name": "review-outreach-email",
"contentKey": "generatedEmail",
"prompt": "Review this AI-generated outreach email before it is sent to the prospect. Check for accuracy, tone, and personalization.",
"timeoutMs": 3600000
}This step pauses the workflow and presents the content stored at workflowState.generatedEmail for human review, with a 1-hour timeout.
How It Works
Step execution
- The engine calls
executeApprovalStep, which reads the content fromworkflowState[contentKey]. - If the key does not exist in state, the step fails immediately with error code
APPROVAL_CONTENT_MISSING. - An
ApprovalRequestrecord is created in the database containing:- The content to review (from state)
- Metadata including the step name and reviewer prompt
- An expiration timestamp (if
timeoutMsis set)
- An
APPROVAL_REQUESTEDevent is emitted. - The step returns
{ status: 'waiting', signalType: 'approval_decision' }and the workflow transitions toWAITING.
Listing pending approvals
Reviewers (or your application) can list pending approval requests:
GET /v1/approval-requests?pending=trueThis returns all approval requests for the workspace that have not yet been decided.
Making a decision
Submit a decision via the approval API:
POST /v1/approval-requests/{id}/decideRequest body:
{
"decision": "approved",
"decidedBy": "reviewer@company.com"
}Decision Schema
| Field | Type | Required | Description |
|---|---|---|---|
decision | "approved" | "rejected" | "edited" | Yes | The reviewer's decision |
editedContent | Record<string, unknown> | No | Modified content (required for "edited") |
decidedBy | string | No | Identifier of the person who decided |
Decision Types
Approved
The reviewer accepts the content as-is. The workflow resumes and advances to the next step. The original content remains in the workflow state.
{
"decision": "approved",
"decidedBy": "alice@company.com"
}Rejected
The reviewer rejects the content. The step is marked as failed, which triggers the retry policy. Since the default maxAttempts is 1, a rejection typically fails the entire workflow. If you want rejections to retry (e.g., regenerate the content with an upstream LLM step), increase maxAttempts.
{
"decision": "rejected",
"decidedBy": "alice@company.com"
}Edited
The reviewer modifies the content before approving. The editedContent replaces the original content in the workflow state, and the workflow resumes with the updated data.
{
"decision": "edited",
"editedContent": {
"subject": "Quick question about your API infrastructure",
"body": "Hi Alice, I noticed your team recently adopted GraphQL..."
},
"decidedBy": "alice@company.com"
}Timeout Behavior
When timeoutMs is set:
- An
expiresAttimestamp is stored on theApprovalRequestrecord. - A delayed BullMQ job is scheduled as a timeout guard.
- If no decision arrives before the timeout, the step is treated as failed and the retry policy applies.
Set timeoutMs to ensure workflows do not remain indefinitely stuck waiting for human input.
Full Workflow Example
This workflow generates an outreach email with an LLM, then gates it on human approval before sending:
{
"name": "ai-sdr-with-review",
"version": "1.0",
"steps": [
{
"type": "task",
"name": "enrich-lead",
"handler": "enrichLeadData"
},
{
"type": "llm",
"name": "draft-email",
"model": "gpt-4o",
"messages": [
{
"role": "user",
"content": "Write a personalized outreach email to {{state.enrichedLead.name}} at {{state.enrichedLead.company}}."
}
],
"responseFormat": "json",
"outputSchema": {
"type": "object",
"properties": {
"subject": { "type": "string" },
"body": { "type": "string" }
},
"required": ["subject", "body"]
}
},
{
"type": "approval",
"name": "review-email",
"contentKey": "subject",
"prompt": "Review this outreach email. Edit the subject or body if needed, or reject to regenerate.",
"timeoutMs": 7200000
},
{
"type": "task",
"name": "send-email",
"handler": "sendOutreachEmail"
}
]
}In this workflow:
- enrich-lead populates
workflowState.enrichedLeadwith company and contact data. - draft-email generates an email. Because
responseFormatis"json", the parsed fields (subject,body) are written to state. - review-email pauses and presents the
subjectfrom state. The reviewer can approve, edit, or reject. - send-email executes only after human approval, using the (possibly edited) content from state.