Build a Human-in-the-Loop Chat
Build a chat app that pauses risky actions for approval.
Build a chat app where risky tool calls pause for a human decision before they run.
Define the tool
Start with a normal server tool and decide at runtime when approval is needed.
import { defineTool } from "@better-agent/core";
import { z } from "zod";
export const sendEmail = defineTool({
name: "send_email",
description: "Send an email to a user.",
schema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
approval: {
resolve: ({ input, context }) => ({
required: context.role !== "admin",
timeoutMs: 300_000,
meta: {
role: context.role,
destination: input.to,
},
}),
},
}).server(async ({ to, subject, body }) => {
await emailClient.send({ to, subject, body });
return { sent: true };
});Define the agent
import { betterAgent, defineAgent } from "@better-agent/core";
import { createOpenAI } from "@better-agent/providers/openai";
import { z } from "zod";
import { sendEmail } from "./tools";
const openai = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const assistant = defineAgent({
name: "assistant",
model: openai.text("gpt-5-mini"),
contextSchema: z.object({
role: z.enum(["admin", "support"]),
}),
instruction: `
Help draft and send emails.
Use the send_email tool when the user asks to send a message.
Confirm what you are doing clearly.
`,
tools: [sendEmail],
});
const app = betterAgent({
agents: [assistant],
baseURL: "/api",
secret: "dev-secret",
});
export default app;Create the client
import { createClient } from "@better-agent/client";
import type app from "./server";
export const client = createClient<typeof app>({
baseURL: "/api",
secret: "dev-secret",
});Build the chat
Use the normal Better Agent hook, then render the pending approvals next to the conversation.
import { useState } from "react";
import { useAgent } from "@better-agent/client/react";
import { client } from "./client";
export function Chat() {
const [input, setInput] = useState("");
const { messages, status, sendMessage, pendingToolApprovals, approveToolCall } = useAgent(
client,
{
agent: "assistant",
context: {
role: "support",
},
},
);
const getText = (message: (typeof messages)[number]) =>
message.parts.map((part) => (part.type === "text" ? part.text : "")).join("");
return (
<div>
<form
onSubmit={async (event) => {
event.preventDefault();
if (!input.trim()) return;
await sendMessage({ input });
setInput("");
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
<button type="submit">Send</button>
</form>
<p>Status: {status}</p>
<ul>
{messages.map((message) => (
<li key={message.localId}>
{message.role}: {getText(message)}
</li>
))}
</ul>
{pendingToolApprovals.map((approval) => (
<div key={approval.toolCallId}>
<p>Approval needed for {approval.toolName}</p>
<pre>{JSON.stringify(approval.input, null, 2)}</pre>
<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",
})
}
>
Deny
</button>
</div>
))}
</div>
);
}This pattern keeps approval inside the same run. The model can request the action, the policy can inspect typed context and tool input, the UI can review it, and the run continues after a human decision.