OpenAI Agents SDK
OpenAI Agents SDK human in the loop approvals. Answer needsApproval interruptions from your user's phone, in TypeScript or Python. No answer means no.
The OpenAI Agents SDK has human in the loop approvals built in: mark a tool with needsApproval and the run stops with interruptions before the tool executes. Somebody still has to decide. @pushary/openai-agents (TypeScript) and pushary-openai-agents (Python) send each interruption to your user's phone, record their answer on the run state, and hand it back so you can continue.
npm i @pushary/openai-agents @openai/agents zod
export PUSHARY_API_KEY=pk_xxx.xxxGet the key at Partner onboarding. The TypeScript package needs Node.js 22 or later. The Python package installs openai-agents 0.18 or later for you.
Set up OpenAI Agents SDK human in the loop
Connect a user once
import { connect } from '@pushary/openai-agents'
const { universalLink } = await connect({ apiKey: process.env.PUSHARY_API_KEY! }, user.id)The user opens the link on their phone once. They install the free Pushary app and never sign up or pay.
Mark the tool and resolve its interruptions
import { Agent, run, tool } from '@openai/agents'
import { z } from 'zod'
import { pusharyNeedsApproval, resolvePusharyInterruptions } from '@pushary/openai-agents'
const issueRefund = tool({
name: 'issue_refund',
description: 'Refund an order',
parameters: z.object({ orderId: z.string(), amount: z.number() }),
needsApproval: pusharyNeedsApproval(),
execute: async ({ orderId, amount }) => refundOrder(orderId, amount),
})
const agent = new Agent({
name: 'Refunds',
instructions: 'Refund orders when the customer asks.',
tools: [issueRefund],
})
let result = await run(agent, 'Refund order 1234 in full.')
while (result.interruptions?.length) {
const outcome = await resolvePusharyInterruptions(
{ externalId: user.id },
{ interruptions: result.interruptions, state: result.state },
)
result = await run(agent, result.state)
if (!outcome.allApproved) break
}refundOrder and refund_order are your own functions.
Run it and answer on the phone
The run stops before issue_refund executes. Your user's phone buzzes with the tool name and its arguments. They tap Approve, and the tool runs when you continue the run.
Each interruption becomes one question on the phone, one at a time, in order.
What happens when nobody answers
The tool does not run. Each question waits up to 55 seconds. A deny or no answer in time rejects the interruption, and the model gets the reason as the rejection message so it does not keep retrying:
| Outcome | Rejection message the model reads |
|---|---|
| Your user taps Deny | The approver denied this action. |
| Nobody answers in time | No answer from the approver, so this was denied. Do not retry the same action. |
allApproved (all_approved in Python) is then false. The loop continues the run once, so the model reads the reason, and then stops.
Let the model ask a question
import { Agent, run } from '@openai/agents'
import { pusharyTool } from '@pushary/openai-agents'
const agent = new Agent({
name: 'Support',
instructions: 'Ask the customer which order they need help with.',
tools: [pusharyTool({ apiKey: process.env.PUSHARY_API_KEY! }, { externalId: user.id })],
})
const result = await run(agent, 'Which order needs help?')The model decides when to call this tool, so it suits questions. Keep needsApproval for anything that must not happen without a yes.
Production notes
Make sure a person always decides
The resolver asks your site's policy first. A policy rule that names the tool can allow or deny it without paging anyone. Pass policy: false (Python: policy=False) when a person must always answer.
Continue from the saved state
In Python, continue with outcome.state. It carries the answers. Continuing from result.to_input_list() replays the conversation without them, so the model asks for the same tool again and your user gets asked twice.
Retries and idempotency
Each question is keyed on the tool call id, the recipient and the arguments, so an exact retry reuses it. Changed arguments or a changed recipient ask again. If you replay a run under ids you mint yourself, pass a stable runId (Python: run_id).
Pick the right person
The user id is bound in your code, never taken from the model, so a tricked model cannot ask the wrong person. For a multi-tenant agent, pass a function as externalId that picks the person for each interruption from your own records.
Answers that come later than 55 seconds
The resolver waits inside one request. For answers that may take hours, save the run state with the decision id, customer and exact call arguments, then resume only that call after you read the answer. The TypeScript delayed-answer recipe does this with SQLite and needs Node.js 22.13 or later. In Python you build the same coordination around the saved state. To wake your worker when an answer lands, use a signed webhook.
Versions
@pushary/openai-agents 0.4 and pushary-openai-agents 0.4 require the shared SDK 2.1. Finish pending runs on their old version before you upgrade.
Source and more examples: pushary-openai-agents on GitHub. OpenAI reference: human in the loop in TypeScript and in Python.
Next steps
Use the hosted Agents API instead
Sessions kept by OpenAI: save the pending call, ask, then submit the tool result.
See the OpenAI Agents SDK approval workflow
The whole flow for OpenAI Agents SDK agents, from connect to resume.
Try a simulated decision
Approve or deny a refund in your browser before you connect a phone.