Human in the Loop
Require human approval before a tool runs.
Human in the Loop pauses execution and waits for an approval decision before a tool runs. Use it when a tool is sensitive, costly, or irreversible.
Common examples:
- deleting data
- issuing refunds
- running infrastructure changes
- triggering client-side actions that need user confirmation
Static Approval
Use approval on the tool contract when the tool should always require approval.
import { defineTool } from "@better-agent/core";
import { z } from "zod";
const deleteDatabase = defineTool({
name: "delete_database",
schema: z.object({
databaseId: z.string(),
}),
approval: {
required: true,
timeoutMs: 300_000,
meta: { risk: "high" },
},
}).server(async ({ databaseId }) => {
await db.delete(databaseId);
return { deleted: true };
});When the agent calls delete_database, Better Agent pauses and waits for a human decision before running the handler. Execution only blocks when the resolved policy ends up with required: true.
Dynamic Approval
Use resolve when approval should depend on runtime input or context.
const refundPayment = defineTool({
name: "refund_payment",
schema: z.object({
orderId: z.string(),
amount: z.number(),
}),
approval: {
resolve: ({ input, context }) => ({
required: input.amount > 100,
timeoutMs: 60_000,
meta: {
amount: input.amount,
risk: input.amount > 500 ? "critical" : "medium",
},
}),
},
}).server(async ({ orderId, amount }) => {
await processRefund(orderId, amount);
return { refunded: amount, orderId };
});The resolver receives:
context: validated agent context for the runinput: validated tool inputrunIdtoolCallIdtoolNametoolTarget:"server"or"client"
The return value can override required, timeoutMs, and meta for that specific call.
Timeouts
Approval timeout can be defined at three levels, checked in order:
- The return value of
approval.resolve() approval.timeoutMson the tool contractadvanced.toolApprovalTimeoutMsas the app-wide fallback
App-wide default:
const app = betterAgent({
agents: [adminAgent],
advanced: {
toolApprovalTimeoutMs: 600_000,
},
});If approval times out, Better Agent emits a TOOL_APPROVAL_UPDATED event with state: "expired" and the run fails with a TIMEOUT error.
Approval Lifecycle
The approval flow uses two event types: TOOL_APPROVAL_REQUIRED and TOOL_APPROVAL_UPDATED.
TOOL_CALL_END
→ TOOL_APPROVAL_REQUIRED (state: "requested")
→ TOOL_APPROVAL_UPDATED (state: "requested")
→ [waiting for human]
→ TOOL_APPROVAL_UPDATED (state: "approved" | "denied" | "expired")
→ TOOL_CALL_RESULTThe TOOL_APPROVAL_UPDATED event carries a state field with one of four values:
"requested": waiting for a decision"approved": human approved, tool will execute"denied": human denied, tool call is skipped"expired": timeout reached, run fails
There are only two event types. Use the state field on TOOL_APPROVAL_UPDATED to distinguish between outcomes.
Submitting Approvals
Core client
await client.submitToolApproval({
agent: "admin",
runId: "run_123",
toolCallId: "call_456",
decision: "approved",
note: "Verified by admin",
actorId: "admin_1",
});decision accepts "approved" or "denied". Both note and actorId are optional and forwarded to the TOOL_APPROVAL_UPDATED event for audit trails.
React client
Approvals are exposed through pendingToolApprovals and approveToolCall.
import { useAgent } from "@better-agent/client/react";
function ApprovalPanel({ client }: { client: typeof myClient }) {
const { pendingToolApprovals, approveToolCall } = useAgent(client, {
agent: "admin",
});
return (
<div>
{pendingToolApprovals.map((approval) => (
<div key={approval.toolCallId}>
<div>{approval.toolName}</div>
<pre>{JSON.stringify(approval.meta, null, 2)}</pre>
<button
onClick={() =>
approveToolCall({
toolCallId: approval.toolCallId,
decision: "approved",
})
}
>
Approve
</button>
<button
onClick={() =>
approveToolCall({
toolCallId: approval.toolCallId,
decision: "denied",
note: "Rejected by reviewer",
})
}
>
Deny
</button>
</div>
))}
</div>
);
}Each pending approval includes:
toolCallIdtoolNameargs: raw JSON argumentstoolTarget:"server"or"client"input: validated inputmeta: metadata from the approval policy
The approveToolCall API is the same across all framework adapters (React, Vue, Svelte, Solid, Preact).
Listening for Events
Use the stream to update approval UI in real time.
for await (const event of client.stream("admin", { input: "Delete project 123" })) {
if (event.type === "TOOL_APPROVAL_REQUIRED") {
showNotification("Approval needed", event.toolCallName);
}
if (event.type === "TOOL_APPROVAL_UPDATED") {
switch (event.state) {
case "approved":
showSuccess("Approved");
break;
case "denied":
showError("Denied");
break;
case "expired":
showError("Approval expired");
break;
}
}
}See Tools for how approval is defined on tool contracts, and Events for the full event reference.