Skip to main content
Built-in - Framework-level agent. Configure only - cannot modify source.

Overview

The HITL (Human-in-the-Loop) agent suspends workflow execution for manual approval. It leverages Cloudflare Durable Objects for stateful, reliable state persistence across workflow suspensions and resumptions, enabling truly durable human-in-the-loop workflows that can pause for hours or days without losing state. Features:
  • Durable Object storage for reliable state persistence across suspensions
  • Stateful workflow resumption - workflows pause and resume seamlessly
  • Multiple notifications: Slack, Email (via MailChannels), Webhook
  • Configurable timeouts with auto-expiry
  • Approval/rejection with comments

Required Bindings

Add this to your wrangler.toml:
# Required: Durable Object for HITL state
[[durable_objects.bindings]]
name = "HITL_STATE"
class_name = "HITLState"

[[migrations]]
tag = "v1"
new_classes = ["HITLState"]

Actions

The HITL agent supports two primary actions for controlling workflow execution:

suspend

Pauses workflow execution and waits for human approval. The workflow state is persisted to a Durable Object, allowing it to resume from the exact same point later.
agents:
  - name: await-approval
    agent: hitl
    config:
      action: suspend
      timeout: 86400000  # 24 hours in ms
      notificationChannel: slack
      notificationConfig:
        webhookUrl: "$env.SLACK_WEBHOOK_URL"
        baseUrl: "https://my-worker.workers.dev"
    input:
      approvalData:
        content: ${generate-content.output}
        author: ${input.author}

resume

Resumes a suspended workflow with approval/rejection data. This is typically called via the callback URL mechanism.
# Resume happens automatically via callback POST
POST /callback/:token
Body: {
  approved: true/false,
  feedback?: string,
  reason?: string,
  approver?: string
}

Durable Objects Integration

The HITL agent uses Cloudflare Durable Objects to provide stateful human-in-the-loop workflows: Why Durable Objects?
  • State persistence - Workflow state survives across Worker invocations
  • Global consistency - Each workflow gets a unique Durable Object instance
  • Long-lived workflows - Support for approvals that take hours or days
  • Automatic cleanup - State is removed after approval/rejection or timeout
How it works:
  1. When suspend is called, workflow state is serialized to a Durable Object
  2. A unique token is generated for resumption
  3. Notifications are sent with callback URLs containing the token
  4. When the callback URL is invoked, the Durable Object state is retrieved
  5. Workflow resumes from the exact point where it was suspended
  6. State is cleaned up after resumption or timeout

Basic Usage

agents:
  - name: generate-content
    operation: think
    config:
      prompt: Write a blog post about ${input.topic}

  - name: request-approval
    agent: hitl
    inputs:
      data: ${generate-content.output}
      prompt: "Review this blog post"
      approvers: [${env.ADMIN_EMAIL}]

  - name: publish
    condition: ${request-approval.output.approved}
    operation: http
    config:
      url: https://api.example.com/publish
      body: ${generate-content.output}

Inputs

inputs:
  data:
    type: any
    required: true
    description: Data to review

  prompt:
    type: string
    required: true
    description: Review instructions

  approvers:
    type: array
    required: true
    description: Email addresses of approvers

  timeout:
    type: number
    default: 86400  # 24 hours
    description: Approval timeout (seconds)

  minApprovals:
    type: number
    default: 1
    description: Minimum approvals required

  metadata:
    type: object
    description: Additional context

Configuration

Single Approver

agents:
  - name: approve
    agent: hitl
    inputs:
      data: ${previous.output}
      prompt: "Review this change"
      approvers: [[email protected]]

Multiple Approvers

agents:
  - name: approve
    agent: hitl
    inputs:
      data: ${previous.output}
      prompt: "Review this change"
      approvers:
        - [email protected]
        - [email protected]
      minApprovals: 2  # Both must approve

With Timeout

agents:
  - name: approve
    agent: hitl
    inputs:
      data: ${previous.output}
      prompt: "Review within 1 hour"
      approvers: [[email protected]]
      timeout: 3600  # 1 hour

  - name: handle-timeout
    condition: ${approve.output.status === 'timeout'}
    operation: email
    config:
      to: ${env.ESCALATION_EMAIL}
      subject: "Approval timeout"

With Metadata

agents:
  - name: approve
    agent: hitl
    inputs:
      data: ${previous.output}
      prompt: "Review this transaction"
      approvers: [[email protected]]
      metadata:
        amount: ${previous.output.amount}
        customer: ${input.customer_name}
        risk_score: ${calculate-risk.output.score}

Complete Workflows

Content Approval

ensemble: content-approval

agents:
  # 1. Generate content
  - name: generate
    operation: think
    config:
      prompt: Write about ${input.topic}

  # 2. Request approval
  - name: approve
    agent: hitl
    inputs:
      data: ${generate.output}
      prompt: |
        Review this content for:
        - Accuracy
        - Tone
        - Brand guidelines
      approvers:
        - [email protected]
        - [email protected]
      minApprovals: 2

  # 3. Publish if approved
  - name: publish
    condition: ${approve.output.approved}
    operation: http
    config:
      url: https://cms.example.com/publish
      body: ${generate.output}

  # 4. Notify rejection
  - name: notify-rejection
    condition: ${approve.output.rejected}
    operation: email
    config:
      to: ${env.CONTENT_TEAM}
      subject: "Content rejected"
      body: |
        Reason: ${approve.output.reason}
        Feedback: ${approve.output.feedback}

output:
  status: ${approve.output.status}
  published: ${approve.output.approved}

Transaction Approval

ensemble: transaction-approval

agents:
  # 1. Calculate risk
  - name: risk-check
    operation: think
    config:
      prompt: Assess risk for ${input.transaction}

  # 2. Auto-approve low risk
  - name: auto-approve
    condition: ${risk-check.output.risk_score < 0.3}
    operation: code
    config:
      script: scripts/auto-approve
    input:
      riskScore: ${risk-check.output.risk_score}

  # 3. Request approval for high risk
  - name: manual-review
    condition: ${risk-check.output.risk_score >= 0.3}
    agent: hitl
    inputs:
      data: ${input.transaction}
      prompt: "High-risk transaction - manual review required"
      approvers:
        - [email protected]
        - [email protected]
      metadata:
        risk_score: ${risk-check.output.risk_score}
        amount: ${input.transaction.amount}
        customer: ${input.transaction.customer}

  # 4. Process if approved
  - name: process
    condition: ${auto-approve.output.approved || manual-review.output.approved}
    operation: http
    config:
      url: https://payment-api.example.com/process
      body: ${input.transaction}
// scripts/auto-approve.ts
import type { AgentExecutionContext } from '@ensemble-edge/conductor'

export default function autoApprove(context: AgentExecutionContext) {
  const { riskScore } = context.input
  return { approved: true, auto: true }
}

Code Review

ensemble: code-review

agents:
  # 1. Run automated checks
  - name: lint
    operation: code
    config:
      script: scripts/run-linter
    input:
      code: ${input.diff}

  - name: test
    operation: code
    config:
      script: scripts/run-tests
    input:
      code: ${input.diff}

  # 2. Request human review
  - name: review
    condition: ${lint.output.passed && test.output.passed}
    agent: hitl
    inputs:
      data:
        diff: ${input.diff}
        lint_results: ${lint.output}
        test_results: ${test.output}
      prompt: |
        Review this code change:
        - Code quality
        - Test coverage
        - Security concerns
      approvers:
        - [email protected]
        - [email protected]
      minApprovals: 1

  # 3. Merge if approved
  - name: merge
    condition: ${review.output.approved}
    operation: http
    config:
      url: https://api.github.com/repos/${input.repo}/pulls/${input.pr_number}/merge
      method: PUT
// scripts/run-linter.ts
import type { AgentExecutionContext } from '@ensemble-edge/conductor'

export default function runLinter(context: AgentExecutionContext) {
  const { code } = context.input
  // Run linter on the code
  return { passed: true }
}
// scripts/run-tests.ts
import type { AgentExecutionContext } from '@ensemble-edge/conductor'

export default function runTests(context: AgentExecutionContext) {
  const { code } = context.input
  // Run tests on the code
  return { passed: true }
}

Output Schema

{
  status: 'pending' | 'approved' | 'rejected' | 'timeout';
  approved: boolean;
  rejected: boolean;
  approvals: Array<{
    approver: string;
    approved: boolean;
    timestamp: string;
    feedback?: string;
  }>;
  reason?: string;      // Rejection reason
  feedback?: string;    // Approver feedback
  timedOut: boolean;
}

Notification Channels

The HITL agent supports three notification channels to alert approvers:

Slack Notifications

agents:
  - name: await-approval
    agent: hitl
    config:
      action: suspend
      timeout: 86400000  # 24 hours in ms
      notificationChannel: slack
      notificationConfig:
        webhookUrl: "$env.SLACK_WEBHOOK_URL"
        baseUrl: "https://my-worker.workers.dev"
    input:
      approvalData:
        content: ${generate-content.output}
        author: ${input.author}
Slack notifications include:
  • Formatted approval request with emoji
  • Execution ID and approval data
  • Approve/Reject buttons as URLs
  • Expiration countdown

Email Notifications (via MailChannels)

agents:
  - name: await-approval
    agent: hitl
    config:
      action: suspend
      notificationChannel: email
      notificationConfig:
        to: "[email protected]"
        from: "[email protected]"
        subject: "Approval Required"  # Optional
        baseUrl: "https://my-worker.workers.dev"
    input:
      approvalData:
        content: ${generate-content.output}
Email notifications use the MailChannels API which is free for Cloudflare Workers. The sender email must be from a domain you control.

Webhook Notifications

agents:
  - name: await-approval
    agent: hitl
    config:
      action: suspend
      notificationChannel: webhook
      notificationConfig:
        webhookUrl: "https://your-system.com/approval-webhook"
        baseUrl: "https://my-worker.workers.dev"
    input:
      approvalData:
        content: ${generate-content.output}
Webhook payload:
{
  "executionId": "hitl_abc123...",
  "approvalData": { ... },
  "callbackUrl": "https://my-worker.workers.dev/callback/hitl_abc123",
  "approveUrl": "https://my-worker.workers.dev/callback/hitl_abc123?action=approve",
  "rejectUrl": "https://my-worker.workers.dev/callback/hitl_abc123?action=reject",
  "expiresAt": 1732924800000
}

Callback URLs

When a workflow is suspended for approval, Conductor generates a unique resumption token. The approval/rejection is handled via callback URLs:
# Resume with approval/rejection data
POST /callback/:token
Body: { approved: true/false, feedback?: string, reason?: string }

# Get token metadata (without consuming it)
GET /callback/:token
The base path (/callback) is configurable via APIConfig.hitl.resumeBasePath.

Security Model

The callback URLs use token-based authentication - the token itself IS the auth (like a password reset link):
  • Token is cryptographically generated (crypto.randomUUID())
  • Token is one-time use (deleted after resumption)
  • Token has expiration (configured via timeout)
  • Token is delivered via secure channel (notification to authorized user)

Example: Custom Approval UI

// Get approval context
const response = await fetch(`https://your-worker.workers.dev/callback/${token}`);
const { metadata } = await response.json();

// Display approval UI with metadata...

// User clicks approve
await fetch(`https://your-worker.workers.dev/callback/${token}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    approved: true,
    feedback: 'Looks good!',
    approver: '[email protected]'
  })
});

// Or user clicks reject
await fetch(`https://your-worker.workers.dev/callback/${token}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    approved: false,
    reason: 'Needs revision',
    feedback: 'Please fix the formatting'
  })
});

Best Practices

1. Set Reasonable Timeouts
timeout: 86400  # 24 hours for content review
timeout: 3600   # 1 hour for urgent approvals
timeout: 604800 # 1 week for major decisions
2. Handle Timeouts Gracefully
agents:
  - name: approve
    agent: hitl
    inputs:
      timeout: 3600

  - name: escalate
    condition: ${approve.output.status === 'timeout'}
    operation: email
    config:
      to: ${env.ESCALATION_EMAIL}
3. Provide Context
inputs:
  prompt: |
    Review this transaction for:
    - Fraud indicators
    - Risk score > 0.7
    - Amount > $10,000
  metadata:
    risk_factors: ${risk-check.output.factors}
    customer_history: ${customer-history.output}
4. Use Appropriate Approver Counts
# Single approver for routine
minApprovals: 1

# Multiple for critical
minApprovals: 2

# Majority for committees
minApprovals: ${Math.ceil(approvers.length / 2)}
5. Track Rejections
agents:
  - name: approve
    agent: hitl

  - name: log-rejection
    condition: ${approve.output.rejected}
    operation: data
    config:
      backend: d1
      binding: DB
      operation: execute
      sql: INSERT INTO rejections (data, reason, timestamp) VALUES (?, ?, ?)
      params:
        - ${approve.input.data}
        - ${approve.output.reason}
        - ${now()}

Common Use Cases

Expense Approval

agents:
  - name: approve-expense
    agent: hitl
    inputs:
      data:
        amount: ${input.amount}
        category: ${input.category}
        receipt: ${input.receipt_url}
      prompt: "Approve this expense"
      approvers: [${input.manager_email}]
      timeout: 172800  # 48 hours

Marketing Campaign

agents:
  - name: approve-campaign
    agent: hitl
    inputs:
      data: ${generate-campaign.output}
      prompt: "Review campaign before launch"
      approvers:
        - [email protected]
        - [email protected]
      minApprovals: 2

Data Export

agents:
  - name: approve-export
    agent: hitl
    inputs:
      data:
        query: ${input.query}
        row_count: ${preview.output.count}
        includes_pii: ${check-pii.output.detected}
      prompt: "Approve data export"
      approvers:
        - [email protected]

Limitations

  • Max timeout: 7 days (default: 24 hours)
  • Notification channels: Slack, Email, Webhook (no Teams yet)
  • No delegation: Approvers can’t delegate to others
  • Single state per token: Each suspension creates a unique token

Next Steps