AgentFieldbuild

Human-in-the-loop

Pause agent execution for human approval with crash-safe state and webhook callbacks

Execution pauses, waits for approval, resumes

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 off

What 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 waiting in 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, or expired, 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
  1. The agent creates an approval request on an external service and calls app.pause() with the resulting ID
  2. The SDK registers a callback future, then tells the control plane to transition the execution to waiting
  3. The control plane persists the approval metadata (request ID, URL, expiry, callback URL) to PostgreSQL
  4. When the human responds, the external service sends a webhook to POST /api/v1/webhooks/approval-response
  5. The control plane verifies the HMAC signature, updates the execution state, and notifies the agent via its callback URL
  6. The agent's pause() call resolves with the ApprovalResult
Decision outcomes and patterns

Decision Outcomes

DecisionExecution State AfterDescription
approvedrunningAgent resumes normally
rejectedrunningAgent resumes with the rejection result
request_changesrunningAgent resumes with feedback for iteration
expiredrunningAgent 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()

ParameterTypeDefaultDescription
approval_request_idstrrequiredID from the external approval service
approval_request_urlstr""URL where the human can review the request
expires_in_hoursint72Hours until the approval expires
timeoutfloat | NoneNoneMax seconds to wait; defaults to expires_in_hours
execution_idstr | Nonecurrent contextOverride which execution to pause

Returns: ApprovalResult with fields decision, feedback, execution_id, approval_request_id.

TypeScript -- ApprovalClient

MethodDescription
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:

FieldTypeDefaultDescription
titlestring"Approval Request"Display title
descriptionstring""Description for the reviewer
templateTypestring"plan-review-v1"UI template to render
payloadRecord<string, any>{}Custom data for the template
projectIdstringrequiredProject identifier
expiresInHoursnumber72Hours until expiry

Go -- client.RequestApproval() / client.WaitForApproval()

MethodSignature
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:

FieldTypeDefaultDescription
PollIntervaltime.Duration5sInitial polling interval
MaxIntervaltime.Duration60sMaximum polling interval
BackoffFactorfloat642.0Exponential 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
) -> ApprovalResult

Example -- 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.