Human in the Loop
Use human in the loop when an agent should pause before continuing. Common cases are approving a sensitive server tool or collecting input from the user's app.
Better Agent represents these pauses as AG-UI interrupts. Interrupts have stable ids, so your UI can show a pending decision, keep it durable with the run, and resume later.
Human in the loop requires Storage to be configured for runs to store interrupted runs and resume later.
Require approval
Add approval to a server tool when it needs a human decision before execution.
import { defineTool } from "@better-agent/core";
import { z } from "zod";
const refundTool = defineTool({
name: "refund",
target: "server",
description: "Issue a refund.",
inputSchema: z.object({
orderId: z.string(),
amount: z.number(),
}),
approval: {
resolve: ({ toolInput }) => toolInput.amount > 50,
},
async execute({ orderId, amount }) {
return stripe.refunds.create({ payment_intent: orderId, amount });
},
});When the model calls refund for more than 50, the run pauses before
execute runs. The raw interrupt includes an id, reason, tool call id, optional
metadata, and any expiry. Client hooks turn that into a pending approval with
the tool name and parsed input.
const result = await app.agent("support").run({
threadId: "thread_123",
messages: [{ role: "user", content: "Refund order #123 for $200." }],
});
if (result.outcome === "interrupt") {
const approval = result.interrupts[0];
console.log(approval.id, approval.metadata);
}Handle approvals in React
The React hook exposes pending approvals and resumes automatically after you approve or reject them.
import { useAgent } from "@better-agent/client/react";
import { client } from "@/better-agent/client";
function SupportChat() {
const agent = useAgent(client.agent("support"), {
threadId: "thread_123",
});
if (agent.pendingToolApprovals.length > 0) {
return (
<div>
{agent.pendingToolApprovals.map((approval) => (
<section key={approval.interruptId}>
<h3>Approve {approval.toolName}?</h3>
<pre>{JSON.stringify(approval.input, null, 2)}</pre>
<button onClick={() => agent.approveToolCall(approval.interruptId)}>
Approve
</button>
<button onClick={() => agent.rejectToolCall(approval.interruptId)}>
Deny
</button>
</section>
))}
</div>
);
}
return <Chat messages={agent.messages} onSend={agent.sendMessage} />;
}If there are multiple pending approvals, answer each one. The hook continues the run when no undecided approvals remain.
Approve from the server
Without the client hook, resume the same thread with a resume entry. Manual
resume requires a threadId and Storage.
const resumed = await app.agent("support").run({
threadId: "thread_123",
resume: [
{
interruptId: result.interrupts[0].id,
status: "resolved",
payload: { approved: true },
},
],
});Approving lets the tool execute. Denying sends a denied tool result back to the model so it can respond to the user.
await app.agent("support").run({
threadId: "thread_123",
resume: [
{
interruptId: result.interrupts[0].id,
status: "resolved",
payload: { approved: false },
},
],
});Use status: "cancelled" when you want to cancel the pending action instead.
resume: [
{
interruptId: result.interrupts[0].id,
status: "cancelled",
},
];Approval metadata
resolve can return metadata for your approval UI.
approval: {
resolve: ({ toolInput, context }) => ({
enabled: toolInput.amount > 50,
metadata: {
policy: "refunds_over_50",
reviewerRole: context.role,
},
}),
}When the user answers, you can send response metadata too.
await agent.approveToolCall(approval.interruptId, {
reviewerId: "usr_123",
note: "Customer is eligible.",
});Timeouts
Set a timeout when an approval should expire.
const refundTool = defineTool({
name: "refund",
target: "server",
inputSchema: z.object({ orderId: z.string(), amount: z.number() }),
approval: { enabled: true },
interrupt: {
approval: { timeoutMs: 60_000 },
},
async execute({ orderId, amount }) {
return stripe.refunds.create({ payment_intent: orderId, amount });
},
});The interrupt includes expiresAt. Use it to render a countdown or hide stale
approval actions.
Client tools
Client tools are another kind of pause. They run in the user's app instead of on the server.
const confirmAddress = defineTool({
name: "confirm_address",
target: "client",
description: "Ask the user to confirm their shipping address.",
inputSchema: z.object({
address: z.string(),
orderId: z.string(),
}),
});If a matching client handler is registered, the client resolves the tool
automatically. If not, the UI can read pendingClientTools and render its own
pending-tool UI. See Tools for client tool examples.
How it works
- The model calls a tool.
- The runtime checks whether approval or client-side input is needed.
- If yes, the run returns with
outcome: "interrupt". - Your UI or server resolves the interrupt.
- The run resumes from the same thread.
Important fields:
| Field | Use |
|---|---|
id | The interrupt id. Pass it as interruptId when resuming. |
reason | Why the run paused, such as tool_approval_pending. |
toolCallId | The tool call that created the pause. |
responseSchema | The expected shape of the resume payload. |
metadata | Extra context for your UI. |
expiresAt | When the pending decision expires. |