Back to Cookbook

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.

tools.ts
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

server.ts
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

client.ts
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.

Chat.tsx
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.