Skip to content

Human-in-the-loop

This guide covers the SDK’s approval-based human-in-the-loop flow. When a tool call requires approval, the SDK pauses the run, returns interruptions, and lets you resume later from the same RunState.

That approval surface is run-wide, not limited to the current top-level agent. The same pattern applies when the tool belongs to the current agent, to an agent reached through a handoff, or to a nested agent.asTool() execution. In the nested agent.asTool() case, the interruption still surfaces on the outer run, so you approve or reject it on the outer result.state and resume the original root run.

With agent.asTool(), approvals can happen at two different layers: the agent tool itself can require approval via asTool({ needsApproval }), and tools inside the nested agent can later raise their own approvals after the nested run starts. Both are handled through the same outer-run interruption flow.

This page focuses on the manual approval flow via interruptions. If your app can decide in code, some tool types also support programmatic approval callbacks so the run can continue without pausing. If you are setting up agent.asTool() itself, see the tools guide; this page covers what happens once any tool in that run hierarchy needs approval.

You can define a tool that requires approval by setting the needsApproval option to true or to an async function that returns a boolean.

Tool approval definition
import { tool } from '@openai/agents';
import z from 'zod';
const sensitiveTool = tool({
name: 'cancelOrder',
description: 'Cancel order',
parameters: z.object({
orderId: z.number(),
}),
// always requires approval
needsApproval: true,
execute: async ({ orderId }, args) => {
// prepare order return
},
});
const sendEmail = tool({
name: 'sendEmail',
description: 'Send an email',
parameters: z.object({
to: z.string(),
subject: z.string(),
body: z.string(),
}),
needsApproval: async (_context, { subject }) => {
// check if the email is spam
return subject.includes('spam');
},
execute: async ({ to, subject, body }, args) => {
// send email
},
});
  1. When a tool invocation is about to execute, the SDK evaluates its approval rule (needsApproval or the hosted MCP equivalent).
  2. If approval is required and no decision is stored yet, the tool call does not execute. Instead, the run records a RunToolApprovalItem.
  3. At the end of that turn, the run pauses and returns all pending approvals in the result interruptions array. This includes approvals raised inside nested agent.asTool() runs.
  4. Resolve each pending item with result.state.approve(interruption) or result.state.reject(interruption). Pass { alwaysApprove: true } or { alwaysReject: true } if the same tool should stay approved or rejected for the rest of the run. When rejecting, you can also pass { message: '...' } to control the rejection text that is sent back to the model for that specific tool call.
  5. Resume by passing the updated result.state back into runner.run(agent, state), where agent is the original top-level agent for the run. The SDK continues from the interrupted point, including nested agent-tool executions.

Sticky decisions created with { alwaysApprove: true } or { alwaysReject: true } are stored in the run state, so they survive toString() / fromString() when you resume the same paused run later.

Computer tool interruptions can represent a batch of actions in one computer_call on GA models. The SDK evaluates needsApproval per action before execution, so one pending approval can cover a sequence such as move + click. If you inspect interruption.rawItem to render a UI, handle both the GA actions array and the legacy single action field.

Serialized RunState also preserves computer approvals across both the current computer tool name and the legacy computer_use_preview name, so paused runs can resume cleanly during preview-to-GA migrations.

If you do not provide message, the SDK falls back to the configured toolErrorFormatter (if any) and then to the default rejection text.

You do not need to resolve every pending approval in the same pass. If you rerun after approving or rejecting only some items, those resolved calls can continue while unresolved ones remain in interruptions and pause the run again.

Manual interruptions are the most general pattern, but they are not the only one:

  • Local shellTool() and applyPatchTool() can use onApproval to approve or reject immediately in code.
  • Hosted MCP tools can use requireApproval together with onApproval for the same kind of programmatic decision.
  • Plain function tools use the manual interruption flow on this page.

When these callbacks return a decision, the run continues without pausing for a human response. For Realtime / voice session APIs, see the approval flow in the voice agents build guide.

The same interruption flow works in streaming runs. After a streamed run pauses, wait for stream.completed, read stream.interruptions, resolve them, and call run() again with { stream: true } if you want the resumed output to keep streaming. See Human in the loop while streaming for the streamed version of this pattern.

If you are also using a session, keep passing the same session when you resume from RunState. The resumed turn is then appended to session memory without re-preparing the input. See the sessions guide for the session lifecycle details.

Below is a more complete example of a human-in-the-loop flow that prompts for approval in the terminal and temporarily stores the state in a file.

Human in the loop
import { z } from 'zod';
import readline from 'node:readline/promises';
import fs from 'node:fs/promises';
import { Agent, run, tool, RunState, RunResult } from '@openai/agents';
const getWeatherTool = tool({
name: 'get_weather',
description: 'Get the weather for a given city',
parameters: z.object({
location: z.string(),
}),
needsApproval: async (_context, { location }) => {
// forces approval to look up the weather in San Francisco
return location === 'San Francisco';
},
execute: async ({ location }) => {
return `The weather in ${location} is sunny`;
},
});
const dataAgentTwo = new Agent({
name: 'Data agent',
instructions: 'You are a data agent',
handoffDescription: 'You know everything about the weather',
tools: [getWeatherTool],
});
const agent = new Agent({
name: 'Basic test agent',
instructions: 'You are a basic agent',
handoffs: [dataAgentTwo],
});
async function confirm(question: string) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await rl.question(`${question} (y/n): `);
const normalizedAnswer = answer.toLowerCase();
rl.close();
return normalizedAnswer === 'y' || normalizedAnswer === 'yes';
}
async function main() {
let result: RunResult<unknown, Agent<unknown, any>> = await run(
agent,
'What is the weather in Oakland and San Francisco?',
);
let hasInterruptions = result.interruptions?.length > 0;
while (hasInterruptions) {
// storing
await fs.writeFile(
'result.json',
JSON.stringify(result.state, null, 2),
'utf-8',
);
// from here on you could run things on a different thread/process
// reading later on
const storedState = await fs.readFile('result.json', 'utf-8');
const state = await RunState.fromString(agent, storedState);
for (const interruption of result.interruptions) {
const confirmed = await confirm(
`Agent ${interruption.agent.name} would like to use the tool ${interruption.name} with "${interruption.arguments}". Do you approve?`,
);
if (confirmed) {
state.approve(interruption);
} else {
state.reject(interruption);
}
}
// resume execution of the current state
result = await run(agent, state);
hasInterruptions = result.interruptions?.length > 0;
}
console.log(result.finalOutput);
}
main().catch((error) => {
console.dir(error, { depth: null });
});

See the full example script for a working end-to-end version.

The human-in-the-loop flow is designed to be interruptible for longer periods of time without keeping your server running. If you need to shut down the request and continue later on you can serialize the state and resume later.

You can serialize the state using result.state.toString() (or JSON.stringify(result.state)) and resume later on by passing the serialized state into RunState.fromString(agent, serializedState) where agent is the instance of the agent that triggered the overall run.

If the resumed process needs to inject a fresh context object, use RunState.fromStringWithContext(agent, serializedState, context, { contextStrategy }) instead.

  • contextStrategy: 'merge' (default) keeps the provided RunContext, merges in the serialized approval state, and restores serialized toolInput when the new context does not already define one.
  • contextStrategy: 'replace' rebuilds the run using the provided RunContext as-is.

Serialized run state includes your app context plus SDK-managed runtime metadata such as approvals, usage, nested toolInput, and pending nested agent-tool resumptions. If you plan to store or transmit serialized state, treat runContext.context as persisted data and avoid placing secrets there unless you intentionally want them to travel with the state.

By default tracing API keys are omitted from serialized state so you do not accidentally persist secrets. Pass result.state.toString({ includeTracingApiKey: true }) only when you intentionally need to move tracing credentials with the state.

That way you can store your serialized state in a database, or along with your request.

If your approval requests take a longer time and you intend to version your agent definitions in a meaningful way or bump your Agents SDK version, we currently recommend for you to implement your own branching logic by installing two versions of the Agents SDK in parallel using package aliases.

In practice this means assigning your own code a version number and storing it along with the serialized state and guiding the deserialization to the correct version of your code.