In the OpenAI Agents SDK for TypeScript, give a tool needsApproval (true, or a function of its arguments). When the model calls it, the run stops and returns the call in result.interruptions. Keep result.state on your server, send the browser only the tool name and arguments, render each as an approval card, then call state.approve or state.reject and run the agent again with that state.
How the Agents SDK pauses for approval#
The SDK's Human-in-the-loop guide describes the flow: before a tool runs, the SDK checks its approval rule; if approval is needed and no decision is stored, the call does not run and the turn ends with every pending approval in interruptions. You resolve each with result.state.approve(item) or result.state.reject(item), optionally with { message } for the text the model sees, and resume with run(agent, state).
The state can wait. result.state.toString() serializes it, and RunState.fromString(agent, saved) restores it later, so a person can approve tomorrow without a server holding the request open.
The guide is firm about where that state lives. Its server example keeps snapshots on the server, sends the browser only display values, binds each request to the authenticated user, consumes a request once, and warns that a snapshot sent through the client controls execution unless you verify its integrity. Follow that shape; the code below does.
From interruption to card#
| Agents SDK | Approval card |
|---|---|
result.interruptions has items |
One card per item, status="pending" |
item.name |
The tool, in the card's source |
item.arguments (a JSON string) |
Parsed into items rows and the title |
state.approve(item), then run(agent, state) |
onApprove, which resolves when the server answers |
state.reject(item, { message }) |
onReject(note), the note as message |
| The resumed run finishes | The card folds into its receipt |
The code#
The agent and a small approval store. Credits over $50 wait for a person.
import { randomUUID } from "node:crypto";
import { Agent, run, RunState, tool, type RunResult } from "@openai/agents";
import { z } from "zod";
const issueCredit = tool({
name: "issue_credit",
description: "Issue an account credit to a customer",
parameters: z.object({ customer: z.string(), amountUsd: z.number(), reason: z.string() }),
needsApproval: async (_context, { amountUsd }) => amountUsd > 50,
execute: async ({ customer, amountUsd }) => `Credited ${customer} $${amountUsd}`,
});
export const agent = new Agent({
name: "Billing agent",
instructions: "Resolve billing tickets. When a credit is rejected, do not retry it; say what the reviewer asked for.",
tools: [issueCredit],
});
export type Prompt = { decisionId: string; toolName: string; arguments: string };
export type Decision = { approved: boolean; note?: string };
export type AgentReply = { kind: "done"; output: string } | { kind: "approval"; requestId: string; prompts: Prompt[] };
// Use your database: one row per request, owner checked and deleted in one statement.
const pending = new Map<string, { ownerId: string; snapshot: string; decisionIds: string[] }>();
function reply(ownerId: string, result: RunResult<unknown, Agent<unknown, any>>): AgentReply {
const interruptions = result.interruptions ?? [];
if (!interruptions.length) return { kind: "done", output: String(result.finalOutput ?? "") };
const requestId = randomUUID();
const decisionIds = interruptions.map(() => randomUUID());
pending.set(requestId, { ownerId, snapshot: result.state.toString(), decisionIds });
return {
kind: "approval",
requestId,
prompts: interruptions.map((item, i) => ({ decisionId: decisionIds[i]!, toolName: item.name ?? "unknown_tool", arguments: item.arguments ?? "{}" })),
};
}
export async function start(ownerId: string, message: string) {
return reply(ownerId, await run(agent, message));
}
export async function decide(ownerId: string, requestId: string, decisions: Record<string, Decision>) {
const stored = pending.get(requestId);
if (!stored || stored.ownerId !== ownerId) throw new Error("That approval request is no longer open.");
if (!stored.decisionIds.every((id) => decisions[id])) throw new Error("Decide every pending tool call.");
pending.delete(requestId);
const state = await RunState.fromString(agent, stored.snapshot);
state.getInterruptions().forEach((item, i) => {
const decision = decisions[stored.decisionIds[i]!]!;
if (decision.approved) state.approve(item);
else state.reject(item, { message: decision.note || "The reviewer rejected this credit." });
});
return reply(ownerId, await run(agent, state));
}import { decide, start, type Decision } from "../../../lib/billing-agent";
// Take the user from your session (Clerk, Auth.js, your own cookie), never from the request body.
declare function currentUserId(req: Request): Promise<string>;
export async function POST(req: Request) {
const userId = await currentUserId(req);
const body: { message: string } | { requestId: string; decisions: Record<string, Decision> } = await req.json();
const reply = "message" in body ? await start(userId, body.message) : await decide(userId, body.requestId, body.decisions);
return Response.json(reply);
}The client shows one card per pending call and sends the decisions once every card is decided.
"use client";
import { useRef } from "react";
import { ApprovalCard } from "@/components/pixilui/approval-card";
import type { AgentReply, Decision, Prompt } from "../lib/billing-agent";
type Credit = { customer: string; amountUsd: number; reason: string };
export function BillingApprovals({ reply, onReply }: { reply: Extract<AgentReply, { kind: "approval" }>; onReply: (next: AgentReply) => void }) {
const decisions = useRef<Record<string, Decision>>({});
async function record(prompt: Prompt, decision: Decision) {
decisions.current[prompt.decisionId] = decision;
if (Object.keys(decisions.current).length < reply.prompts.length) return; // wait for the other cards
const response = await fetch("/api/agent", { method: "POST", body: JSON.stringify({ requestId: reply.requestId, decisions: decisions.current }) });
if (!response.ok) throw new Error("Not sent"); // the card says nothing changed and offers the buttons again
onReply((await response.json()) as AgentReply);
}
return reply.prompts.map((prompt) => {
const credit = JSON.parse(prompt.arguments) as Credit;
return (
<ApprovalCard
key={prompt.decisionId}
title={`Credit $${credit.amountUsd.toFixed(2)} to ${credit.customer}`}
risk={credit.amountUsd >= 500 ? "high" : "medium"}
items={[
{ label: "Amount", value: `$${credit.amountUsd.toFixed(2)}`, numeric: true },
{ label: "Reason", value: credit.reason },
]}
source={prompt.toolName}
labels={{ approve: "Approve credit" }}
onApprove={() => record(prompt, { approved: true })}
onReject={(note) => record(prompt, { approved: false, note })}
/>
);
});
}Install the card with npx shadcn@latest add @pixilui/approval-card.
What the card adds to the interruption#
- Readable arguments.
item.argumentsis a JSON string; the card shows the amount and reason as rows, amounts in tabular figures. - Risk. A $600 credit is
risk="high"and asks for a second press. - A note that reaches the model. The reject note becomes the
messagethe SDK sends back for that call, so the agent knows what to change. - Honest pending. The last card's promise waits for the server; if the request fails, the card says nothing changed and offers the buttons again.
When the framework already ships a UI#
The TypeScript Agents SDK runs on the server, and its guides list no React components; the approval example asks in a terminal prompt. OpenAI's ChatKit is a chat UI with React bindings (@openai/chatkit-react), widgets and tool-invocation support, and can sit in front of an agent you run on your own server. Agent Builder has a human approval node, but OpenAI's ChatKit docs say Agent Builder is being deprecated and shuts down on November 30, 2026, so do not start new approval flows there. If ChatKit already renders your chat, its widgets are a fair choice; the card here is for approvals in your own React screens.
When not to use this#
- Approval you can decide in code.
needsApprovaltakes a function; return false for the safe cases instead of asking. - The client holds the state. If you cannot keep snapshots on the server, read the guide's integrity and replay warnings before building this.
- A queue of requests. Many open approvals across runs belong in an inbox, not under each chat message.
Questions#
How does needsApproval work in the OpenAI Agents SDK?
Set it on a tool to true or to an async function of the run context and the tool's arguments. When it returns true, the call does not run; the run pauses with the call in result.interruptions until you approve or reject it and run again with the same state.
How do I resume an OpenAI Agents SDK run after approval?
Call state.approve(item) or state.reject(item) for each interruption, then run(agent, state) with the original top-level agent. If you stored the state, restore it with RunState.fromString(agent, saved) first.
Can I send the RunState to the browser?
The SDK guide recommends keeping snapshots on the server and sending only display values. A snapshot controls execution; if a client must carry one, the guide says to verify its integrity, bind it to the user and run, and prevent replay.
Does the rejection reason reach the model?
Yes. state.reject(item, { message }) sets the rejection text the model receives for that call.
Related#
- Human-in-the-loop approval for AI agents: what a good approval step shows.
- Human approval with the Vercel AI SDK: the same card on
toolApproval. - Human approval with LangGraph: the same card on an
interrupt(). - Tool call UI with the OpenAI Agents SDK: rows for the calls that run without a person.
Sources#
- OpenAI Agents SDK (TypeScript): Human-in-the-loop, read 4 November 2026
- OpenAI Agents SDK (TypeScript): Tools, read 4 November 2026
- OpenAI: ChatKit, read 4 November 2026
- OpenAI: Agent Builder node reference, read 4 November 2026