Skip to content

Latest commit

 

History

History

README.md

Human-in-the-Loop Workflow

Workflows that pause for human input and resume when ready — built with iii-sdk.

This example shows how to build durable workflows that can pause indefinitely waiting for external signals (a user clicking a button, a manager approving an order) and resume exactly where they left off.

The Pattern

  1. Save state after each step as a checkpoint
  2. Workflow pauses by saving awaiting_approval status (no publish)
  3. Webhook API resumes the flow when the external system calls it

No magic. Just state + API endpoints as re-entry points.

The Flow

POST /orders → order::submit
                    ↓ publish('order.submitted')
              order::analyze-risk
               ↙              ↘
    riskScore ≤ 70          riskScore > 70
         ↓                       ↓
  publish('order.auto_approved')  PAUSE (save state, no publish)
         ↓                       ↓
  order::complete         POST /webhooks/orders/:id/approve
                                 ↓
                          publish('order.approved')
                                 ↓
                          order::complete

  Cron: order::detect-timeouts (every 5 min)

Quick Start

pnpm install

Configure the engine to run this worker by pointing to iii-config.yaml, then start the engine:

cargo run --release

Or run the worker directly in dev mode:

bun run --watch src/main.ts

Test It

Submit a high-risk order (will pause):

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": "pending_analysis", "message": "..."}

The workflow pauses at awaiting_approval — check state to confirm.

Resume it (minutes/hours/days later):

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"
  }'

The workflow continues — loads state, publishes order.approved, completes fulfillment.

Run Test Script

./test-htl-flow.sh

Project Structure

src/
  lib/iii.ts              SDK init (registerWorker + Logger) and shared types
  handlers/
    submit-order.ts       HTTP POST /orders — entry point
    analyze-risk.ts       Durable subscriber on 'order.submitted'
    approval-webhook.ts   HTTP POST /webhooks/orders/:orderId/approve — re-entry point
    complete-order.ts     Durable subscriber on 'order.approved' + 'order.auto_approved'
    detect-timeouts.ts    Cron — finds stuck orders every 5 minutes
  main.ts                 Entry point — imports all handlers

Handlers call iii.trigger() directly with built-in function IDs — see the iii state and iii queue docs for the full list of operations.

How It Works

Pausing: save state, don't publish

if (riskScore > 70) {
  order.status = 'awaiting_approval'
  order.currentStep = 'awaiting_approval'
  await iii.trigger({
    function_id: 'state::set',
    payload: { scope: 'orders', key: orderId, value: order },
  })
  // No publish — workflow stops here
}

Resuming: webhook loads checkpoint and continues

const order = await iii.trigger({
  function_id: 'state::get',
  payload: { scope: 'orders', key: orderId },
})

if (order.currentStep !== 'awaiting_approval') {
  return { status_code: 400, body: { error: 'Not awaiting approval' } }
}

order.status = 'approved'
await iii.trigger({
  function_id: 'state::set',
  payload: { scope: 'orders', key: orderId, value: order },
})
await iii.trigger({
  function_id: 'iii::durable::publish',
  payload: { topic: 'order.approved', data: { orderId } },
})

Multiple triggers on one function

order::complete subscribes to both order.approved and order.auto_approved:

iii.registerTrigger({
  type: 'durable:subscriber',
  function_id: ref.id,
  config: { topic: 'order.approved' },
})

iii.registerTrigger({
  type: 'durable:subscriber',
  function_id: ref.id,
  config: { topic: 'order.auto_approved' },
})

State Structure

{
  "id": "abc-123",
  "status": "awaiting_approval",
  "currentStep": "awaiting_approval",
  "completedSteps": ["submitted", "risk_analysis"],
  "riskScore": 85,
  "requiresApproval": true,
  "approvedBy": null,
  "approvedAt": null,
  "createdAt": "2026-01-05T10:00:00Z",
  "updatedAt": "2026-01-05T10:00:02Z"
}

Migrated from Motia

This example was migrated from the Motia human-in-the-loop example. Key changes:

Motia iii-sdk
motia package iii-sdk@0.11.0
export const config + export const handler iii.registerFunction() + iii.registerTrigger()
state.set() / state.get() / state.getGroup() iii.trigger({ function_id: 'state::set' | 'state::get' | 'state::list', payload })
emit({ topic, data }) iii.trigger({ function_id: 'iii::durable::publish', payload: { topic, data } })
return { status: 200 } return { status_code: 200 }
req.pathParams input.path_params
cron: '*/5 * * * *' (5-field) expression: '0 */5 * * * * *' (7-field)
Noop step for visualization Removed (no equivalent needed)
motia dev bun run --watch src/main.ts
motia build esbuild

License

Apache-2.0