Motia Icon

Human-in-the-Loop Workflows

Build workflows that pause for human decisions and resume when ready

Some workflows need humans to make decisions.

Maybe it's approving a high-value order. Or reviewing content before publishing. Or signing off on a production deployment. These workflows need to pause, wait for a human decision, and continue when the decision arrives.

Motia handles this naturally. You save your progress, stop emitting events, and create an API webhook for the human to call. When they make their decision, the webhook picks up right where you left off.

How It Works

Human-in-the-Loop workflows in Motia work like this:

  1. Process automatically when possible - Let the system handle what it can
  2. Save "awaiting" state when human needed - Mark the pause point clearly
  3. Stop emitting - The workflow naturally pauses
  4. Webhook resumes the flow - External systems call an API to continue

No special "pause" or "wait" commands. Just save state and use API steps as re-entry points.


Key Ideas

ConceptWhat It Means
Workflows don't sleepThey don't "wait." They save state and stop processing.
API steps are re-entry pointsExternal systems call webhooks to restart the flow
State checkpointingEvery state.set() is a save point you can resume from
Virtual connectionsUse virtualSubscribes to show how webhooks fit in the flow visually

The workflow doesn't have a concept of time. It only knows: "Is the right state present? Has the right step been triggered?"


Real Example: Order Approval

Let's build an order processing system that auto-approves low-risk orders but pauses for human review on high-risk ones.

Example Location: examples/foundational/workflow-patterns/human-in-the-loop/

View on GitHub →

The Flow

HTL Example

Step 1: Submit Order

An API receives the order and saves it immediately:

src/01-submit-order.step.ts
import { ApiRouteConfig, Handlers } from 'motia'
 
export const config: ApiRouteConfig = {
  type: 'api',
  name: 'SubmitOrder',
  path: '/orders',
  method: 'POST',
  emits: ['order.submitted'],
  flows: ['order-approval']
}
 
export const handler: Handlers['SubmitOrder'] = async (req, { state, emit, logger }) => {
  const orderId = crypto.randomUUID()
  
  const order = {
    id: orderId,
    items: req.body.items,
    total: req.body.total,
    status: 'pending_analysis',
    currentStep: 'submitted',
    completedSteps: ['submitted'],  // Track progress for idempotency
    createdAt: new Date().toISOString()
  }
  
  // Save immediately - this is our first checkpoint
  await state.set('orders', orderId, order)
  
  logger.info('Order submitted', { orderId })
  
  // Kick off risk analysis
  await emit({ 
    topic: 'order.submitted', 
    data: { orderId } 
  })
  
  return { status: 201, body: { orderId, status: order.status } }
}

👉 Key point: We save the order immediately with currentStep and completedSteps. This becomes our checkpoint system.

Step 2: Analyze Risk

This step decides whether to auto-approve or pause for human review:

src/02-analyze-risk.step.ts
import { EventConfig, Handlers } from 'motia'
 
export const config: EventConfig = {
  type: 'event',
  name: 'AnalyzeRisk',
  subscribes: ['order.submitted'],
  flows: ['order-approval'],
  emits: ['order.auto_approved'],
  virtualEmits: [{ topic: 'approval.required', label: 'Requires human approval' }]
}
 
export const handler: Handlers['AnalyzeRisk'] = async (input, { state, emit, logger }) => {
  const { orderId } = input
  const order = await state.get('orders', orderId)
  
  // Skip if already analyzed (idempotency)
  if (order.completedSteps.includes('risk_analysis')) {
    return
  }
  
  // Calculate risk score
  const riskScore = calculateRiskScore(order)
  order.riskScore = riskScore
  order.completedSteps.push('risk_analysis')
  
  if (riskScore > 70) {
    // High risk - PAUSE for human approval
    order.status = 'awaiting_approval'
    order.currentStep = 'awaiting_approval'
    await state.set('orders', orderId, order)
    
    logger.warn('Order requires approval - workflow paused', { orderId, riskScore })
    
    // NO EMIT - workflow stops here
    // Webhook will restart it when human makes decision
    
  } else {
    // Low risk - bypass gate and auto-approve
    order.status = 'approved'
    order.approvedBy = 'system'
    order.completedSteps.push('approved')
    await state.set('orders', orderId, order)
    
    logger.info('Order auto-approved', { orderId, riskScore })
    
    // Continue immediately
    await emit({ topic: 'order.auto_approved', data: { orderId } })
  }
}
 
function calculateRiskScore(order: any): number {
  let score = 0
  if (order.total > 1000) score += 40
  else if (order.total > 500) score += 20
  
  const itemCount = order.items.reduce((sum, item) => sum + item.quantity, 0)
  if (itemCount > 10) score += 30
  
  score += Math.random() * 40
  return Math.min(Math.round(score), 100)
}

👉 The key decision:

  • High risk: Save "awaiting" state and don't emit → workflow stops
  • Low risk: Emit immediately → workflow continues

Step 3: Human Approval Gate (Visual Noop)

This creates a visual node in Workbench showing where the workflow pauses:

src/03-human-approval-gate.step.ts
import type { NoopConfig } from 'motia'
 
export const config: NoopConfig = {
  type: 'noop',
  name: 'HumanApprovalGate',
  description: 'Workflow pauses here - awaiting human decision via webhook',
  flows: ['order-approval'],
  
  // Receives signal that approval is needed
  virtualSubscribes: ['approval.required'],
  
  // Shows connection to webhook that continues flow
  virtualEmits: ['human.decision'],
}

👉 In Workbench: This Noop appears between AnalyzeRisk and ApprovalWebhook.

Step 4: Approval Webhook (Re-Entry Point)

External systems (UI, Slack, etc.) call this webhook to provide the human decision:

src/04-approval-webhook.step.ts
import { ApiRouteConfig, Handlers } from 'motia'
 
export const config: ApiRouteConfig = {
  type: 'api',
  name: 'ApprovalWebhook',
  path: '/webhooks/orders/:orderId/approve',
  method: 'POST',
  emits: ['order.approved'],
  virtualSubscribes: ['human.decision'],  // Shows connection from Noop
  flows: ['order-approval']
}
 
export const handler: Handlers['ApprovalWebhook'] = async (req, { state, emit, logger }) => {
  const { orderId } = req.pathParams
  const { approved, approvedBy, notes } = req.body
  
  // Load the checkpoint
  const order = await state.get('orders', orderId)
  
  if (!order) {
    return { status: 404, body: { error: 'Order not found' } }
  }
  
  // Verify we're at the right pause point
  if (order.currentStep !== 'awaiting_approval') {
    return { status: 400, body: { error: 'Order not awaiting approval' } }
  }
  
  if (approved) {
    // Apply decision
    order.status = 'approved'
    order.approvedBy = approvedBy
    order.approvedAt = new Date().toISOString()
    order.completedSteps.push('approved')
    await state.set('orders', orderId, order)
    
    logger.info('Order approved - resuming workflow', { orderId, approvedBy })
    
    // Resume the workflow
    await emit({ topic: 'order.approved', data: { orderId } })
    
    return { status: 200, body: { success: true, message: 'Order approved' } }
  } else {
    // Rejected - workflow ends
    order.status = 'rejected'
    order.rejectedBy = approvedBy
    order.rejectionReason = notes
    await state.set('orders', orderId, order)
    
    logger.info('Order rejected - workflow ends', { orderId })
    return { status: 200, body: { success: true, message: 'Order rejected' } }
  }
}

👉 The pattern: Load checkpoint → Verify state → Apply decision → Resume or end

Step 5: Complete Order

Final step that runs after approval:

src/05-complete-order.step.ts
import { EventConfig, Handlers } from 'motia'
 
export const config: EventConfig = {
  type: 'event',
  name: 'CompleteOrder',
  subscribes: ['order.approved', 'order.auto_approved'],
  flows: ['order-approval'],
  emits: []
}
 
export const handler: Handlers['CompleteOrder'] = async (input, { state, logger }) => {
  const { orderId } = input
  const order = await state.get('orders', orderId)
  
  // Skip if already completed (idempotency)
  if (order.completedSteps.includes('completed')) {
    return
  }
  
  // Process fulfillment
  await simulateFulfillment(order)
  
  order.status = 'completed'
  order.completedAt = new Date().toISOString()
  order.completedSteps.push('completed')
  await state.set('orders', orderId, order)
  
  logger.info('Order completed', { orderId, approvedBy: order.approvedBy })
}
 
async function simulateFulfillment(order: any) {
  // In production: charge payment, create shipment, send email
  await new Promise(resolve => setTimeout(resolve, 1000))
}

Recovery Pattern: Timeout Detection

What happens if a human never responds? In production, you need a safety mechanism to detect and escalate stuck workflows.

Step 6: Timeout Detection (Cron)

A scheduled job periodically scans for orders stuck in approval:

src/06-detect-timeouts.step.ts
import { CronConfig, Handlers } from 'motia'
 
export const config: CronConfig = {
  type: 'cron',
  name: 'DetectTimeouts',
  description: 'Find orders stuck awaiting approval and escalate',
  cron: '*/5 * * * *',  // Every 5 minutes (for demo - use hourly in production)
  flows: ['order-approval'],
  emits: []
}
 
export const handler: Handlers['DetectTimeouts'] = async ({ state, logger }) => {
  const orders = await state.getGroup('orders')
  const now = Date.now()
  const timeoutMs = 10 * 60 * 1000  // 10 minutes (demo - use 24 hours in production)
  
  let stuckCount = 0
  
  for (const order of orders) {
    // Find orders stuck in awaiting_approval
    if (order.status === 'awaiting_approval') {
      const lastUpdate = new Date(order.updatedAt || order.createdAt).getTime()
      const age = now - lastUpdate
      
      if (age > timeoutMs) {
        stuckCount++
        
        logger.warn('Approval timeout detected', {
          orderId: order.id,
          ageMinutes: Math.round(age / (60 * 1000)),
          riskScore: order.riskScore,
          total: order.total
        })
        
        // Mark as timed out
        order.status = 'timeout'
        order.timeoutAt = new Date().toISOString()
        order.timeoutReason = `No approval within ${Math.round(timeoutMs / (60 * 1000))} min`
        await state.set('orders', order.id, order)
        
        // In production, take action:
        // 1. Send escalation notification (Slack, email)
        // 2. Auto-reject if too old
        // 3. Assign to different manager
        // 4. Create support ticket
        
        logger.info('Escalation triggered', {
          orderId: order.id,
          action: 'timeout_escalation',
          notifyChannel: '#urgent-approvals'
        })
      }
    }
  }
  
  if (stuckCount > 0) {
    logger.info('Timeout detection complete', { 
      stuckCount,
      totalOrders: orders.length 
    })
  }
}

👉 Why this matters: Without timeout detection, orders could be stuck forever. This cron job acts as a safety net, ensuring no workflow is forgotten.

Production actions:

  • 📧 Send escalation emails/Slack messages
  • 🔄 Reassign to different approvers
  • ⏰ Auto-reject after threshold
  • 🎫 Create support tickets for investigation

🎨 Visual Flow in Workbench

When you open the example in Workbench, you'll see 5 nodes:

HTL Example

  1. SubmitOrder (green) - API entry point
  2. AnalyzeRisk (blue) - Risk calculation and decision point
  3. HumanApprovalGate (gray Noop) - ⏸️ Pause indicator showing where workflow stops
  4. ApprovalWebhook (green) - Re-entry point for human decisions
  5. CompleteOrder (blue) - Final fulfillment step

The virtual connections (dashed lines) show:

  • approval.required → HumanApprovalGate
  • human.decision → ApprovalWebhook

This visualizes exactly where external systems restart the flow. Low-risk orders bypass the gate entirely - they flow directly from AnalyzeRisk to CompleteOrder.


Trying It Out

Ready to see it in action? Let's get the project running.

Try it yourself:

Install Dependencies

First, install the necessary npm packages.

npm install

Run the Project

Start the Motia development server.

npm run dev

Open http://localhost:3000 in your browser to access the Workbench. You'll see the HumanApprovalGate (Noop node) showing where the workflow pauses for human decisions.

Test High-Risk Order (Will Pause)

Submit an order with high value to trigger the approval gate:

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{"name": "Expensive Item", "price": 500, "quantity": 3}],
    "customerEmail": "test@example.com",
    "total": 1500
  }'

Response:

{
  "orderId": "abc-123",
  "status": "awaiting_approval"
}

Check Workbench: The flow stops at HumanApprovalGate. No more steps run. The workflow is paused and waiting for human decision.

Resume via Webhook (Hours/Days Later)

When you're ready (could be minutes, hours, or even days later), approve the order:

# User clicks "Approve" button in UI → Makes this call
curl -X POST http://localhost:3000/webhooks/orders/abc-123/approve \
  -H "Content-Type: application/json" \
  -d '{
    "approved": true,
    "approvedBy": "manager@company.com",
    "notes": "Verified customer identity"
  }'

Check Workbench: Flow resumes! It loads state, emits order.approved, and CompleteOrder runs to fulfill the order.

Test Low-Risk Order (Bypasses Gate)

Submit a low-value order that auto-approves:

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{"name": "Widget", "price": 10, "quantity": 1}],
    "customerEmail": "test@example.com",
    "total": 10
  }'

Check Workbench: Flows straight through SubmitOrder → AnalyzeRisk → CompleteOrder. The HumanApprovalGate is completely bypassed!


State Management

Your state tracks the workflow position:

{
  id: 'abc-123',
  status: 'awaiting_approval',      // What state we're in
  currentStep: 'awaiting_approval',  // Where we paused
  completedSteps: ['submitted', 'risk_analysis'],  // What's done
  
  // Business data
  items: [...],
  total: 1500,
  riskScore: 85,
  
  // Approval tracking
  approvedBy: null,      // Filled in by webhook
  approvedAt: null,
  
  // Timestamps
  createdAt: '2026-01-05T10:00:00Z',
  updatedAt: '2026-01-05T10:00:02Z',
}

When the webhook is called, it:

  1. Loads this state
  2. Verifies currentStep === 'awaiting_approval'
  3. Updates with approval info
  4. Emits to continue

💻 Dive into the Code

Want to explore the complete implementation? Check out the full source code and additional examples in our GitHub repository:

Explore More Examples

Get hands-on with the complete source code, configuration files, and additional examples to accelerate your learning.


Summary

Building Human-in-the-Loop workflows in Motia:

  1. Save state when pausing - Mark clearly with status: 'awaiting_something'
  2. Don't emit - Workflow stops naturally
  3. Create webhook API - This is your re-entry point
  4. Use virtual connections - Show the pause visually in Workbench with Noops
  5. External systems call webhook - UI, Slack, etc. restart the flow
  6. Idempotent steps - Always check completedSteps before doing work

Your workflow can pause for minutes, hours, or days. When the webhook is called, it loads state and continues exactly where it left off.


Use Cases

This pattern works for:

  • Order approvals - High-value or risky purchases
  • Content moderation - Review before publishing
  • Document signing - Wait for signatures
  • Deployment approvals - Manager sign-off for production
  • Support escalations - Human agent intervention
  • Compliance review - Legal approval required
Need help? See our Community Resources for questions, examples, and discussions.
Human-in-the-Loop Workflows | motia