Human-in-the-Loop
Pause agent execution for human review before high-stakes decisions
Human-in-the-Loop
Production-grade human approval workflows for autonomous agents
Autonomous agents make thousands of decisions. Most are routine. But some — approving a $500k transaction, deploying to production, sending a mass email to 200,000 users — need a human in the loop. You need agents that know when to pause and ask.
The hard part isn't detecting when to pause. It's everything that happens next: persisting state across an indefinite wait, notifying the right reviewer, handling timeouts, recovering from crashes while waiting, and resuming cleanly with the human's decision. That's infrastructure, not business logic.
Agentfield provides that infrastructure. One call pauses execution. The control plane holds state. The agent resumes when a human responds.
What You'd Otherwise Build
Traditional Approval Flows
What you build:
- Polling system to check approval status
- Webhook handlers for reviewer notifications
- State machine to track approval lifecycle
- Timeout enforcement and expiry logic
- Crash recovery for agents waiting on approval
- Reviewer UI or integration with ticketing systems
- Audit trail for every approval decision
Then you write business logic.
Agentfield Approval Flows
What you write:
result = await app.pause(
approval_request_id=f"claim-{claim_id}",
approval_request_url=f"https://dashboard.example.com/claims/{claim_id}",
expires_in_hours=24,
)Agentfield provides:
- ✓ Durable waiting state
- ✓ Timeout enforcement
- ✓ Crash recovery
- ✓ Webhook notifications
- ✓ Audit trail
- ✓ Dashboard integration
- ✓ Full observability
The Autonomy Problem
Here's what breaks when you try to add human review to autonomous agents:
Scenario: An AI agent processes insurance claims. It handles 500 claims per day. Most are straightforward — minor fender benders, routine medical expenses — and the agent auto-approves them in seconds. But a $2M claim for a commercial property loss comes in. That needs a human.
Traditional approaches fail in predictable ways:
Blocking threads. The agent pauses and holds a thread open waiting for a human response. The reviewer takes 6 hours. Your thread pool is exhausted. Other claims queue up. The system degrades.
Lost state on crash. You move the wait to a background job. The job runner restarts at 3am. The agent's in-memory state — the claim analysis, the risk assessment, the context it built up — is gone. The agent either re-runs from scratch (wasting compute) or fails silently.
No timeout handling. The reviewer goes on vacation. The claim sits in pending forever. No escalation. No expiry. No fallback.
No audit trail. Six months later, the claimant disputes the decision. You have no record of who approved it, when, or why. Compliance fails.
Agentfield's approach: await app.pause() suspends execution at the control plane level. The agent process is free. State is persisted durably. The control plane enforces timeouts, sends notifications, and resumes the agent with the reviewer's decision — whether that happens in 5 minutes or 5 days.
How It Works
Agent detects a high-stakes decision
Your business logic determines when human review is needed — risk score above a threshold, transaction amount over a limit, a destructive operation that can't be undone. The agent has already done its analysis at this point.
if assessment.risk_score >= 70 or amount >= 50000:
# This needs a humanAgent calls app.pause() with approval request details
The agent provides an ID for the approval request and optionally a URL where the reviewer can take action. The call blocks in the agent process until a decision arrives.
result = await app.pause(
approval_request_id=f"claim-{claim_id}",
approval_request_url=f"https://dashboard.example.com/claims/{claim_id}",
expires_in_hours=24,
)Control plane sets execution to waiting state
The execution record transitions from running to waiting. The agent process is suspended — no threads held, no resources consumed. The control plane registers the approval request and starts the expiry clock.
Human reviews via dashboard or webhook notification
The control plane notifies configured reviewers. The reviewer sees the approval request URL, the agent's context, and the decision options: approve, reject, or request changes.
Human submits a decision
The reviewer approves, rejects, or requests changes — optionally with a comment. The control plane records the decision with a timestamp and the reviewer's identity.
Agent resumes with the decision
The execution transitions back to running. The app.pause() call returns an ApprovalResult. The agent continues from exactly where it left off, with the human's decision in hand.
if result.approved:
return {"decision": "approved", "method": "human"}SDK Coverage
from agentfield import Agent
from pydantic import BaseModel
app = Agent("claim-processor")
class ClaimAssessment(BaseModel):
risk_score: int # 0-100
fraud_indicators: list[str]
recommended_action: str # "auto_approve", "review", "reject"
summary: str
@app.reasoner()
async def process_claim(claim_id: str, amount: float, description: str):
# AI analyzes the claim
assessment = await app.ai(
system="You are an insurance claim assessor. Evaluate risk and fraud indicators.",
user=f"Claim #{claim_id}: ${amount:,.2f} - {description}",
schema=ClaimAssessment,
)
app.note(f"Risk score: {assessment.risk_score}/100", tags=["assessment"])
# Low risk: auto-approve
if assessment.risk_score < 40 and amount < 10000:
app.note("Auto-approved: low risk, low value", tags=["decision"])
return {"decision": "approved", "method": "automatic"}
# High risk or high value: pause for human review
app.note(
f"Escalating for human review: risk={assessment.risk_score}, amount=${amount:,.2f}",
tags=["escalation"],
)
result = await app.pause(
approval_request_id=f"claim-{claim_id}",
approval_request_url=f"https://dashboard.example.com/claims/{claim_id}",
expires_in_hours=24,
)
if result.approved:
app.note(f"Approved by reviewer. Feedback: {result.feedback}", tags=["decision"])
return {"decision": "approved", "method": "human", "feedback": result.feedback}
if result.changes_requested:
app.note(f"Changes requested: {result.feedback}", tags=["decision"])
return {"decision": "needs_review", "feedback": result.feedback}
app.note(f"Rejected by reviewer. Reason: {result.feedback}", tags=["decision"])
return {"decision": "rejected", "reason": result.feedback}package main
import (
"context"
"fmt"
"time"
agentfield "github.com/agentfield/sdk-go"
)
func processClaim(ctx context.Context, client *agentfield.Client, claimID string, amount float64) error {
// Request approval from a human reviewer
result, err := client.RequestApproval(ctx, &agentfield.ApprovalRequest{
ApprovalRequestID: fmt.Sprintf("claim-%s", claimID),
ApprovalRequestURL: fmt.Sprintf("https://dashboard.example.com/claims/%s", claimID),
ExpiresInHours: 24,
})
if err != nil {
return fmt.Errorf("requesting approval: %w", err)
}
// Wait for the reviewer's decision (blocks until approved, rejected, or expired)
approval, err := client.WaitForApproval(ctx, result.ApprovalRequestID, &agentfield.WaitOptions{
Timeout: 24 * time.Hour,
})
if err != nil {
return fmt.Errorf("waiting for approval: %w", err)
}
switch approval.Decision {
case "approved":
return processApprovedClaim(ctx, claimID, approval.Feedback)
case "request_changes":
return flagForReview(ctx, claimID, approval.Feedback)
default:
return rejectClaim(ctx, claimID, approval.Feedback)
}
}import { AgentFieldClient } from "@agentfield/sdk";
const client = new AgentFieldClient({ serverUrl: process.env.AGENTFIELD_SERVER });
async function processClaim(claimId: string, amount: number): Promise<ClaimDecision> {
// Request approval from a human reviewer
const result = await client.requestApproval({
approvalRequestId: `claim-${claimId}`,
approvalRequestUrl: `https://dashboard.example.com/claims/${claimId}`,
expiresInHours: 24,
});
// Wait for the reviewer's decision
const approval = await client.waitForApproval(result.approvalRequestId, {
timeout: 24 * 60 * 60 * 1000, // 24 hours in ms
});
if (approval.decision === "approved") {
return { decision: "approved", method: "human", feedback: approval.feedback };
}
if (approval.decision === "request_changes") {
return { decision: "needs_review", feedback: approval.feedback };
}
return { decision: "rejected", reason: approval.feedback };
}The ApprovalResult
When app.pause() returns, it gives you an ApprovalResult with everything the agent needs to continue:
| Field | Type | Description |
|---|---|---|
decision | str | One of: "approved", "rejected", "request_changes", "expired", "error" |
feedback | str | None | The reviewer's comment, reason, or instructions |
.approved | bool | Convenience property — True when decision == "approved" |
.changes_requested | bool | Convenience property — True when decision == "request_changes" |
The expired decision fires when the approval request exceeds expires_in_hours with no response. Your agent should handle this explicitly — escalate, auto-reject, or retry with a different reviewer.
result = await app.pause(
approval_request_id=f"claim-{claim_id}",
expires_in_hours=24,
)
if result.decision == "expired":
# No response in 24 hours — escalate to senior reviewer
await notify_senior_reviewer(claim_id)
return {"decision": "escalated", "reason": "approval_timeout"}Crash Recovery
Agents crash. Servers restart. Deployments happen mid-approval. Agentfield handles this with wait_for_resume().
When an agent restarts, it can check whether it had a pending approval before the crash and reconnect to it — without re-running the analysis or creating a duplicate approval request:
@app.reasoner()
async def resilient_claim_processor(claim_id: str, amount: float, description: str):
# Check if we crashed while waiting for a previous approval
pending = await app.wait_for_resume(
approval_request_id=f"claim-{claim_id}",
timeout=3600,
)
if pending:
# We had a pending approval — the reviewer may have already decided
if pending.approved:
app.note("Resumed after crash: claim was approved", tags=["recovery"])
return {"decision": "approved", "method": "human", "feedback": pending.feedback}
if pending.changes_requested:
return {"decision": "needs_review", "feedback": pending.feedback}
return {"decision": "rejected", "reason": pending.feedback}
# No pending approval — run the full analysis
assessment = await app.ai(
system="You are an insurance claim assessor.",
user=f"Claim #{claim_id}: ${amount:,.2f} - {description}",
schema=ClaimAssessment,
)
if assessment.risk_score < 40 and amount < 10000:
return {"decision": "approved", "method": "automatic"}
result = await app.pause(
approval_request_id=f"claim-{claim_id}",
approval_request_url=f"https://dashboard.example.com/claims/{claim_id}",
expires_in_hours=24,
)
if result.approved:
return {"decision": "approved", "method": "human", "feedback": result.feedback}
return {"decision": "rejected", "reason": result.feedback}wait_for_resume() is idempotent. Call it at the top of any reasoner that uses app.pause(). If there's no pending approval, it returns None immediately and the agent runs normally.
What the Control Plane Does
When app.pause() is called, the control plane takes over:
Execution state. The execution record transitions to waiting. The agent process is free — no threads held, no memory consumed. The control plane owns the state.
Approval tracking. The control plane registers the approval request with its ID, URL, expiry time, and the execution it belongs to. Multiple executions can have pending approvals simultaneously.
Timeout enforcement. When expires_in_hours elapses with no decision, the control plane automatically resolves the approval with decision: "expired" and resumes the agent. No cron jobs. No manual cleanup.
Notification delivery. Configured webhooks fire when an approval request is created, decided, or expired. Integrate with Slack, PagerDuty, your ticketing system, or any HTTP endpoint.
Audit trail. Every approval event — created, decided, expired — is recorded with a timestamp and the reviewer's identity. The full history is queryable from the dashboard and the API.
Resume on decision. When a reviewer submits a decision, the control plane transitions the execution back to running and delivers the ApprovalResult to the waiting app.pause() call.
What This Enables
For Regulated Industries
Financial services, healthcare, and insurance workflows require documented human oversight. Every approval decision is recorded with reviewer identity, timestamp, and rationale — ready for compliance audits.
For High-Stakes Decisions
Large financial transactions, production deployments, bulk data deletion, and mass communications all benefit from a mandatory human checkpoint before the agent proceeds.
For Collaborative Workflows
Agents handle the analysis and preparation. Humans handle the judgment calls. The agent resumes with the human's decision and continues the workflow — no manual handoff required.
For Production Safety
Crash recovery means a server restart doesn't lose a pending approval. Timeout handling means reviewers going offline doesn't stall the system. The workflow completes regardless.
Next Steps
- Human-in-the-Loop Example — Working code with a complete claim processing workflow
- Python SDK: app.pause() — Full API reference with all parameters
- Identity and Trust — Cryptographic audit trails for every agent action
- Async Execution — Long-running workflows that run for hours or days
Or start building with the Quick Start Guide.