Human-in-the-loop
Pause agent execution for human approval with crash-safe state and webhook callbacks
Pause an agent mid-execution, send a review to a human, and resume automatically when they respond.
Some decisions should not be fully automated. app.pause() blocks the agent until a human approves, rejects, or requests changes -- with crash-safe state persisted in PostgreSQL. The control plane resumes execution automatically when the webhook callback arrives.
@app.reasoner()
async def content_pipeline(brief: str) -> dict:
# Step 1: AI generates a draft
draft = await app.ai(system="You are a senior copywriter.", user=brief)
# Pause #1 — human reviews the draft (state is crash-safe in PostgreSQL)
review = await app.pause(
approval_request_id="draft-review",
approval_request_url="https://cms.example.com/review/draft",
expires_in_hours=24,
)
if review.decision == "rejected":
return {"status": "rejected", "feedback": review.feedback}
# Step 2: AI refines using human feedback
final = await app.ai(
system="Revise based on editorial feedback.",
user=f"Draft: {draft}\nFeedback: {review.feedback}",
)
# Pause #2 — human approves final version before publish
sign_off = await app.pause(
approval_request_id="final-approval",
approval_request_url="https://cms.example.com/review/final",
expires_in_hours=4, # auto-reject if no response
)
if sign_off.decision == "approved":
await app.call("publisher.publish", content=final)
return {"status": "published"}
return {"status": sign_off.decision, "feedback": sign_off.feedback}
# If the process crashes between pauses, it resumes exactly where it left offWhat just happened
- The agent ran normally until it reached a human gate
- Execution state was persisted before waiting, so restarts do not lose progress
- The human response became structured workflow input, not an external side channel
- The control plane resumed the same execution instead of starting a new one
Example paused/resumed lifecycle:
{ "execution_id": "exec_a1b2c3", "status": "waiting", "approval_request_id": "draft-review" }
{ "execution_id": "exec_a1b2c3", "status": "running", "decision": "approved" }
{ "execution_id": "exec_a1b2c3", "status": "succeeded", "result": { "status": "published" } }
What you get
- One-line pause --
app.pause(approval_request_id)blocks the agent until the human responds - Crash-safe state -- the execution transitions to
waitingin PostgreSQL before the agent blocks; restarts pick up where they left off - Webhook-driven resume -- the control plane accepts approval responses via HMAC-signed webhook and immediately resumes the agent
- Multi-pause workflows -- agents can request approval multiple times within a single execution
- Configurable expiry -- default 72 hours; set per request
- Rich decisions --
approved,rejected,request_changes, orexpired, each with optional feedback
Full SDK examples
from agentfield import Agent, AIConfig
app = Agent(node_id="contract-reviewer", version="1.0.0")
@app.reasoner()
async def review_contract(document: str) -> dict:
# AI analyzes the contract
analysis = await app.ai(
system="Analyze this contract for risks.",
user=document,
)
# Pause for human review
app.note("Analysis complete, requesting human approval", ["approval"])
result = await app.pause(
approval_request_id="review_contract_v1",
approval_request_url="https://review.example.com/contracts/123",
expires_in_hours=48,
)
if result.decision == "approved":
return {"status": "approved", "analysis": analysis}
elif result.decision == "request_changes":
return {"status": "needs_changes", "feedback": result.feedback}
else:
return {"status": result.decision, "feedback": result.feedback}
app.run()How it works
- The agent creates an approval request on an external service and calls
app.pause()with the resulting ID - The SDK registers a callback future, then tells the control plane to transition the execution to
waiting - The control plane persists the approval metadata (request ID, URL, expiry, callback URL) to PostgreSQL
- When the human responds, the external service sends a webhook to
POST /api/v1/webhooks/approval-response - The control plane verifies the HMAC signature, updates the execution state, and notifies the agent via its callback URL
- The agent's
pause()call resolves with theApprovalResult
Decision outcomes and patterns
Decision Outcomes
| Decision | Execution State After | Description |
|---|---|---|
approved | running | Agent resumes normally |
rejected | running | Agent resumes with the rejection result |
request_changes | running | Agent resumes with feedback for iteration |
expired | running | Agent resumes with the expiration result |
Multi-Pause Workflow
An agent can request approval multiple times within a single execution. After each approval is resolved, the agent can submit a new approval request:
@app.reasoner()
async def multi_step_review(document: str) -> dict:
# Step 1: AI analysis
analysis = await app.ai(system="Analyze risks.", user=document)
# First pause: review analysis
result = await app.pause(
approval_request_id="step1_review",
approval_request_url="https://review.example.com/step1",
)
if result.decision != "approved":
return {"status": "rejected_at_step_1", "feedback": result.feedback}
# Step 2: Generate recommendations
recommendations = await app.ai(
system="Generate recommendations based on feedback.",
user=f"Analysis: {analysis}\nFeedback: {result.feedback}",
)
# Second pause: review recommendations
result = await app.pause(
approval_request_id="step2_review",
approval_request_url="https://review.example.com/step2",
)
return {
"status": result.decision,
"analysis": analysis,
"recommendations": recommendations,
}Timeout Handling
When expires_in_hours elapses without a response, the approval is resolved as expired and the execution is cancelled. Handle this gracefully:
result = await app.pause(
approval_request_id="time_sensitive_review",
expires_in_hours=4,
timeout=4 * 3600, # match expiry in seconds
)
if result.decision == "expired":
# Escalate or use default behavior
app.note("Approval expired, using default action", ["escalation"])
return {"status": "auto_approved", "reason": "timeout"}SDK reference
Python -- app.pause()
| Parameter | Type | Default | Description |
|---|---|---|---|
approval_request_id | str | required | ID from the external approval service |
approval_request_url | str | "" | URL where the human can review the request |
expires_in_hours | int | 72 | Hours until the approval expires |
timeout | float | None | None | Max seconds to wait; defaults to expires_in_hours |
execution_id | str | None | current context | Override which execution to pause |
Returns: ApprovalResult with fields decision, feedback, execution_id, approval_request_id.
TypeScript -- ApprovalClient
| Method | Description |
|---|---|
requestApproval(executionId, payload) | Transitions execution to waiting, returns {approvalRequestId, approvalRequestUrl} |
getApprovalStatus(executionId) | Returns current status: pending, approved, rejected, expired |
waitForApproval(executionId, opts?) | Polls with exponential backoff until resolved |
RequestApprovalPayload:
| Field | Type | Default | Description |
|---|---|---|---|
title | string | "Approval Request" | Display title |
description | string | "" | Description for the reviewer |
templateType | string | "plan-review-v1" | UI template to render |
payload | Record<string, any> | {} | Custom data for the template |
projectId | string | required | Project identifier |
expiresInHours | number | 72 | Hours until expiry |
Go -- client.RequestApproval() / client.WaitForApproval()
| Method | Signature |
|---|---|
RequestApproval | (ctx, nodeID, executionID string, req RequestApprovalRequest) (*RequestApprovalResponse, error) |
GetApprovalStatus | (ctx, nodeID, executionID string) (*ApprovalStatusResponse, error) |
WaitForApproval | (ctx, nodeID, executionID string, opts *WaitForApprovalOptions) (*ApprovalStatusResponse, error) |
WaitForApprovalOptions:
| Field | Type | Default | Description |
|---|---|---|---|
PollInterval | time.Duration | 5s | Initial polling interval |
MaxInterval | time.Duration | 60s | Maximum polling interval |
BackoffFactor | float64 | 2.0 | Exponential backoff multiplier |
Crash Recovery -- wait_for_resume()
If an agent crashes or restarts while waiting for approval, wait_for_resume() reconnects to a known pending approval request and resumes waiting. The control plane persists all approval state in PostgreSQL, so no data is lost.
Python:
async def wait_for_resume(
approval_request_id: str, # The known approval request ID to reconnect to
execution_id: str | None = None, # Resume a specific execution (default: current)
timeout: float | None = None, # Max seconds to wait
) -> ApprovalResultExample -- crash recovery:
from agentfield import Agent
app = Agent(node_id="contract-reviewer", version="1.0.0")
@app.reasoner()
async def review_contract(document: str) -> dict:
analysis = await app.ai(system="Analyze this contract.", user=document)
result = await app.pause(
approval_request_id="review_v1",
approval_request_url="https://review.example.com/contracts/123",
expires_in_hours=48,
)
return {"status": result.decision, "analysis": analysis}
app.run()If the agent restarts while waiting, you can reconnect to the pending approval:
# Reconnect to a known pending approval — blocks until human responds or timeout
result = await app.wait_for_resume(
approval_request_id="review_v1",
timeout=48 * 3600,
)
app.note(f"Recovered approval: {result.decision}")API reference
Request Approval
POST /api/v1/executions/{execution_id}/request-approval
POST /api/v1/agents/{node_id}/executions/{execution_id}/request-approval
The agent-scoped variant enforces that the execution belongs to the specified agent.
Request body:
{
"approval_request_id": "req_abc123",
"approval_request_url": "https://review.example.com/requests/req_abc123",
"callback_url": "http://agent-host:8001/webhooks/approval",
"expires_in_hours": 48
}Response (200):
{
"approval_request_id": "req_abc123",
"approval_request_url": "https://review.example.com/requests/req_abc123",
"status": "pending"
}Get Approval Status
GET /api/v1/executions/{execution_id}/approval-status
GET /api/v1/agents/{node_id}/executions/{execution_id}/approval-status
Response:
{
"status": "approved",
"response": { "decision": "approved", "feedback": "Looks good" },
"request_url": "https://review.example.com/requests/req_abc123",
"requested_at": "2026-03-23T10:00:00Z",
"responded_at": "2026-03-23T10:30:00Z",
"expires_at": "2026-03-25T10:00:00Z"
}Approval Webhook Callback
POST /api/v1/webhooks/approval-response
This endpoint receives approval decisions from external services. HMAC-SHA256 signature verification is supported via X-Webhook-Signature, X-Hax-Signature, or X-Hub-Signature-256 headers.
Accepted decisions: approved, rejected, request_changes, expired
Normalized aliases: approve/continue/confirm map to approved; reject/deny/abort/cancel map to rejected.