For the complete documentation index, see llms.txt. This page is also available as Markdown.

Human-in-the-Loop

Require a human to approve sensitive tool calls before they run — with pluggable approval policies, durable grants, and batched approvals.

Some tool calls should never run unsupervised. Deleting records, transferring funds, deploying to production, emailing a customer — you want a person to see the call and its arguments, and say yes.

Human-in-the-Loop puts that gate in front of any tool, without changing the tool or the agent's logic. It's HumanInTheLoopMiddleware, plus three collaborators you can swap independently:

Piece
Decides
Default

IApprovalPolicy

whether a call needs approval

match by tool name

IGateway

how the request reaches a human

blocking CLI prompt

IDecisionStore

whether a past "always allow" still applies

settings.hitl.decisionStore (cache)

🚦 The Flow

🖥️ CLI Mode (the default)

With no gateway supplied, a blocking terminal prompt is attached. Good for scripts, CLI tools, and local development.

The prompt offers approve, approve always, approve for session, reject, and quit. Unrecognised input re-prompts (up to three attempts) before cancelling, so a mistyped key doesn't abort the run.

🌐 Web / Async Mode

For web apps and anything where a human isn't sitting at a terminal, run in web mode. The run suspends and checkpoints instead of blocking, and you resume it whenever the decision arrives — minutes or days later.

A checkpointer is required in this mode; without one there is nowhere to save the suspended state.

Later, once a human has decided:

Decisions

Decision
Effect

approve

Run the tool call as requested

approve_always

Run it, and record a durable grant so this tool is auto-approved in future

approve_session

Run it, and auto-approve this tool for the rest of the session

reject

Skip this call; the reason is fed back to the model as the tool result

edit

Replace the call's arguments, then run it

cancel

Stop the whole run

resume() takes ( decision, threadId, editedData, decidedBy, reason ). For streaming, resumeStream() takes the same arguments with onChunk first.

📦 Batched Approvals

When a single turn asks for several tool calls that need approval, they suspend together as one checkpoint — you get one notification listing every pending call, not a drip-feed of one-at-a-time interruptions.

Resuming finishes the whole batch directly against the saved assistant message: the LLM call is not replayed, and nothing that already ran (or was already blocked) happens twice.

Per-call structs accept decision, editedData, decidedBy and reason; anything omitted falls back to the top-level argument.

Batching works across OpenAI, Claude, Bedrock and Cohere. Streaming batches are supported on OpenAI and Claude — the two providers with streaming tool-call support today.

🧭 Approval Policies

The default policy matches tool names. Pass a policy instead when "which calls are risky" is more nuanced than a name list.

Policy
Approves based on
Constructor

ToolNameApprovalPolicy

Tool name (default)

( array toolNames = [] )

RiskLevelApprovalPolicy

A @riskLevel annotation on the tool

( minLevel = "high", defaultLevel = "low" )

AnnotationApprovalPolicy

Presence of an annotation

( annotationName = "requiresApproval" )

CallbackApprovalPolicy

Your own closure

( required function callback )

CompositeApprovalPolicy

Combining several policies

( array policies = [], mode = "any" )

Risk levels are low, medium, high, critical. RiskLevelApprovalPolicy and AnnotationApprovalPolicy both read annotations off the tool class's doInvoke() method:

mode: "any" (the default) is the safer choice — one policy flagging a call is enough to require a human. "all" requires every policy to agree.

🔐 Durable Grants

approve_always and approve_session are only useful if they're remembered. Grants are persisted through a pluggable IDecisionStore, so "always allow this tool for this user" survives the run — and, for approve_always, a restart.

With no decisionStore, the application-wide default from settings.hitl.decisionStore is used:

Store
Backing
Constructor

cache

CacheBox

( cacheName )

jdbc

Any datasource

( required datasource, table )

file

JSON on disk

( directoryPath )

The store contract is small enough to implement yourself — grant(), isGranted(), revoke(), listGrants(). See aiDecisionStore().

Every HumanInTheLoopMiddleware in an application shares one store by design — an application only ever attaches one HITL middleware.

🔎 Inspecting Pending Approvals

Query the middleware directly when you need to build an approvals dashboard:

An AgentSuspension exposes getSuspensionID(), getThreadID(), getStatus(), getReason(), getInteraction(), isPending(), isTerminal(), isExpired() and toStruct().

🔌 Presenting Through a Gateway

CLI and web are the two built-in ends of the spectrum. To present approvals somewhere else — a webhook, a chat platform — attach a gateway:

mode: "cli" and mode: "web" still work exactly as before. When you're attaching a specific gateway anyway, prefer gateway: over mode: — an unrecognised mode falls back to a CLI gateway with a console warning.

  • Gateways — presenting interactions on a platform

  • Middleware — the full middleware pipeline and hooks

  • Agent Memory Management — checkpointers and suspend/resume

  • Security Guide — guardrails around prompts and responses

Last updated