Skip to content
Quick Guides
Quick Guides

Multi-step human approval that survives restarts

Pause execution mid-run for a human decision. State persists in PostgreSQL, so a crashed server resumes exactly where it left off.

Block an agent for a human decision — for hours, days, or across a server restart. The control plane writes the execution state to PostgreSQL before pausing, then a webhook callback resumes the same execution after the human responds.

from agentfield import Agent

app = Agent(node_id="content-pipeline", version="1.0.0")

@app.reasoner()
async def publish_with_review(brief: str) -> dict:
    # Step 1: AI drafts
    draft = await app.ai(system="You are a senior copywriter.", user=brief)

    # Pause #1 — editor reviews the draft (state is now in Postgres)
    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 != "approved":
        return {"status": review.decision, "feedback": review.feedback}

    # Step 2: AI revises using the editor's feedback
    final = await app.ai(
        system="Revise based on editorial feedback.",
        user=f"Draft: {draft}\nFeedback: {review.feedback}",
    )

    # Pause #2 — publisher signs off before going live
    sign_off = await app.pause(
        approval_request_id="final-approval",
        approval_request_url="https://cms.example.com/review/final",
        expires_in_hours=4,
    )
    if sign_off.decision == "approved":
        await app.call("publisher.publish", content=final)
    return {"status": sign_off.decision}

app.run()
import { Agent, ApprovalClient } from "@agentfield/sdk";

const agent = new Agent({ nodeId: "content-pipeline", version: "1.0.0" });

agent.reasoner("publishWithReview", async (ctx) => {
  const { brief } = ctx.input;
  const approvals = new ApprovalClient({
    baseURL: agent.config.agentFieldUrl!,
    nodeId: agent.config.nodeId,
  });

  // Step 1: AI drafts
  const draft = await ctx.ai(brief, { system: "You are a senior copywriter." });

  // Pause #1 — editor reviews the draft (state is now in Postgres)
  await approvals.requestApproval(ctx.executionId, {
    approvalRequestId: "draft-review",
    approvalRequestUrl: "https://cms.example.com/review/draft",
    expiresInHours: 24,
  });
  const review = await approvals.waitForApproval(ctx.executionId, { pollIntervalMs: 5000 });
  if (review.status !== "approved") {
    return { status: review.status, response: review.response };
  }

  // Step 2: AI revises using the editor's feedback
  const final = await ctx.ai(`Draft: ${draft}\nFeedback: ${JSON.stringify(review.response)}`, {
    system: "Revise based on editorial feedback.",
  });

  // Pause #2 — publisher signs off before going live
  await approvals.requestApproval(ctx.executionId, {
    approvalRequestId: "final-approval",
    approvalRequestUrl: "https://cms.example.com/review/final",
    expiresInHours: 4,
  });
  const signOff = await approvals.waitForApproval(ctx.executionId, { pollIntervalMs: 5000 });
  if (signOff.status === "approved") {
    await ctx.call("publisher.publish", { content: final });
  }
  return { status: signOff.status };
});

agent.serve();
package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "github.com/Agent-Field/agentfield/sdk/go/agent"
    "github.com/Agent-Field/agentfield/sdk/go/ai"
    "github.com/Agent-Field/agentfield/sdk/go/client"
)

func main() {
    a, err := agent.New(agent.Config{
        NodeID:        "content-pipeline",
        Version:       "1.0.0",
        AgentFieldURL: "http://localhost:8080",
    })
    if err != nil {
        log.Fatal(err)
    }

    a.RegisterReasoner("publish_with_review", func(ctx context.Context, input map[string]any) (any, error) {
        execCtx := agent.ExecutionContextFrom(ctx)
        c, err := client.New("http://localhost:8080")
        if err != nil {
            return nil, err
        }

        // Step 1: AI drafts
        draft, err := a.AI(ctx, fmt.Sprintf("%v", input["brief"]),
            ai.WithSystem("You are a senior copywriter."))
        if err != nil {
            return nil, err
        }

        // Pause #1 — editor reviews the draft (state is now in Postgres)
        _, err = c.RequestApproval(ctx, "content-pipeline", execCtx.ExecutionID,
            client.RequestApprovalRequest{
                ApprovalRequestID:  "draft-review",
                ApprovalRequestURL: "https://cms.example.com/review/draft",
                ExpiresInHours:     24,
            })
        if err != nil {
            return nil, err
        }
        review, err := c.WaitForApproval(ctx, "content-pipeline", execCtx.ExecutionID,
            &client.WaitForApprovalOptions{PollInterval: 5 * time.Second})
        if err != nil {
            return nil, err
        }
        if review.Status != "approved" {
            return map[string]any{"status": review.Status, "response": review.Response}, nil
        }

        // Step 2: AI revises using the editor's feedback
        final, err := a.AI(ctx, fmt.Sprintf("Draft: %s\nFeedback: %v", draft.Text(), review.Response),
            ai.WithSystem("Revise based on editorial feedback."))
        if err != nil {
            return nil, err
        }

        // Pause #2 — publisher signs off before going live
        _, err = c.RequestApproval(ctx, "content-pipeline", execCtx.ExecutionID,
            client.RequestApprovalRequest{
                ApprovalRequestID:  "final-approval",
                ApprovalRequestURL: "https://cms.example.com/review/final",
                ExpiresInHours:     4,
            })
        if err != nil {
            return nil, err
        }
        signOff, err := c.WaitForApproval(ctx, "content-pipeline", execCtx.ExecutionID,
            &client.WaitForApprovalOptions{PollInterval: 5 * time.Second})
        if err != nil {
            return nil, err
        }
        if signOff.Status == "approved" {
            _, _ = a.Call(ctx, "publisher.publish", map[string]any{"content": final.Text()})
        }
        return map[string]any{"status": signOff.Status}, nil
    })

    log.Fatal(a.Run(context.Background()))
}

If the agent process restarts mid-pause, reconnect to the pending approval:

result = await app.wait_for_resume(
    approval_request_id="draft-review",
    timeout=24 * 3600,
)

What this gives you

  • Multiple human gates inside a single execution.
  • Crash-safe: the execution state lives in PostgreSQL, not in agent memory.
  • Resolutions arrive via HMAC-signed webhook — your reviewer UI just POSTs the decision.

Next