Skip to content
agentFast
The production layer

Human-in-the-loop

Pause on risky tools, survive a restart, execute exactly once.

In short: you decide which actions are too important to happen unsupervised. The agent stops and waits for a person to approve those. The pause survives a crash, and an approved action happens exactly once — never twice, however many times something gets retried.

Most "human in the loop" is a blocking prompt in a notebook. That works until the process dies, the reviewer goes home, or the same call arrives twice. agentFast treats approval as durable state in Postgres, not as a paused coroutine.

Marking a tool risky

tool_overrides:
  refund_request:
    risk: high
    hitl_mode: suspend
  send_email:
    risk: high
    hitl_mode: defer

Anything not listed is low-risk and runs normally.

The two modes

| | suspend | defer | |---|---|---| | The run | Pauses and checkpoints | Continues | | The call returns | APPROVAL_PENDING | APPROVAL_PENDING, treated as "queued" | | Survives a process kill | Yes — that is the pause | The approval row does | | Use for | Money, deletions, anything hard to undo | Work the agent can proceed around |

suspend is the one that needs durability: the run genuinely cannot continue without an answer, so the pause has to outlive the process. defer queues the call and lets the agent get on with whatever else is useful.

Exactly once, by construction

This is the part that's easy to get subtly wrong. Two mechanisms:

Approval identity is derived, not random.

approval_id = f"appr_{run_id}_{tool_call_id}"

The same tool call in the same run always maps to the same row. A model that re-emits a call after a resume finds the existing decision instead of opening a second approval — so re-emission is free rather than dangerous.

Execution is claimed with a compare-and-set.

The first caller claims executed_at atomically and memoizes the result. Every later caller — a re-emitted call, a duplicate resume, a retried worker — gets the stored result back without touching the side effect again.

CarefulThis is why approvals aren't just a boolean

"Has this been approved?" and "has this been executed?" are different questions. Answering only the first is how you end up issuing two refunds for one approval.

Deciding

From the dashboard, the playground, or the CLI:

agentfast approvals list
agentfast approvals approve appr_run_abc_call_1 --by alice@ops
agentfast approvals reject  appr_run_abc_call_1 --by alice@ops --note "customer already refunded"

Or over HTTP:

curl -X POST localhost:8321/api/approvals/appr_run_abc_call_1/decide \
  -H 'Content-Type: application/json' \
  -d '{"decision":"approved","decided_by":"alice@ops"}'

By default an approval on a suspended run auto-resumes it. Pass "auto_resume": false when you want to drive the resume yourself — which is what the playground does so it can stream the resumed run. See HTTP API.

Rejection is information

A rejected call doesn't crash the run. It returns a structured APPROVAL_REJECTED result carrying the reviewer's note back into the agent's reasoning:

{
  "ok": false,
  "error_code": "APPROVAL_REJECTED",
  "message": "A human rejected 'refund_request': customer already refunded"
}

The agent reads that like any other tool failure and adapts — it might apologise, escalate, or try a different path. Which is the point: a reviewer's judgement should teach the agent something, not just stop it.

Watching it live

A pause emits a first-class approval_required event on the SSE stream, so a UI can show the approval card the moment it happens rather than polling for it. See Streaming.