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:
- When
suspend is called, workflow state is serialized to a Durable Object
- A unique token is generated for resumption
- Notifications are sent with callback URLs containing the token
- When the callback URL is invoked, the Durable Object state is retrieved
- Workflow resumes from the exact point where it was suspended
- 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:
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"
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