Stevora

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

FieldTypeRequiredDescription
type"approval"YesStep type discriminator
namestring (1-100 chars)YesUnique step name within the workflow
contentKeystringYesKey in workflowState containing content to review
promptstringNoInstructions shown to the reviewer
timeoutMsnumberNoMaximum time to wait for a decision in milliseconds
retryRetryPolicyNoRetry 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

  1. The engine calls executeApprovalStep, which reads the content from workflowState[contentKey].
  2. If the key does not exist in state, the step fails immediately with error code APPROVAL_CONTENT_MISSING.
  3. An ApprovalRequest record is created in the database containing:
    • The content to review (from state)
    • Metadata including the step name and reviewer prompt
    • An expiration timestamp (if timeoutMs is set)
  4. An APPROVAL_REQUESTED event is emitted.
  5. The step returns { status: 'waiting', signalType: 'approval_decision' } and the workflow transitions to WAITING.

Listing pending approvals

Reviewers (or your application) can list pending approval requests:

GET /v1/approval-requests?pending=true

This 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}/decide

Request body:

{
  "decision": "approved",
  "decidedBy": "reviewer@company.com"
}

Decision Schema

FieldTypeRequiredDescription
decision"approved" | "rejected" | "edited"YesThe reviewer's decision
editedContentRecord<string, unknown>NoModified content (required for "edited")
decidedBystringNoIdentifier 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 expiresAt timestamp is stored on the ApprovalRequest record.
  • 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:

  1. enrich-lead populates workflowState.enrichedLead with company and contact data.
  2. draft-email generates an email. Because responseFormat is "json", the parsed fields (subject, body) are written to state.
  3. review-email pauses and presents the subject from state. The reviewer can approve, edit, or reject.
  4. send-email executes only after human approval, using the (possibly edited) content from state.