ai-ml

OpenAI Agents SDK needsApproval: Null Bug Tested

October 7, 2026

OpenAI Agents SDK needsApproval: Null Bug Tested

TL;DR: In @openai/agents 0.18.0, the needsApproval callback received null for an optional field the model left out, while execute received the field as absent. Version 0.19.0, published October 5, 2026, fixes that.12

I ran both versions with a scripted model. On 0.18.0 a callback that checked cc.length crashed the run, and on 0.19.0 it did not.3 The same release also makes the SDK re-run needsApproval when you resume a saved run, which changes what happens when your policy lookup fails.23

OpenAI Agents SDK needsApproval: the short answer

needsApproval is the option that makes a function tool pause for human approval. You can set it to true or to an async function that returns a boolean.4

Until 0.19.0, that function could see different arguments than execute. For a Zod .optional() field the model left out, the callback got null and execute got nothing.53 On 0.19.0 both see the same normalized input.63

What you'll learn

  • What needsApproval receives, and why strict mode turns optional fields into null
  • How to reproduce the 0.18.0 bug with a scripted model and no API key
  • What changed in 0.19.0 for resumed runs and non-plain input
  • What I measured that did not change
  • A short checklist for writing a safe approval callback

Why optional fields arrive as null

Tool schemas in this SDK are strict by default. The tools guide says validation schemas automatically enable strict mode.7

In strict mode, a Zod .optional() field is sent to the model as required and nullable. I printed the JSON schema the SDK builds for cc: z.array(z.string()).optional() and got cc in required, typed as an array or null.3

So when the model has nothing to put in cc, it can send "cc": null. That is what issue #1914 described, and I reproduced the callback side of it below.53

What changed in @openai/agents 0.19.0

The @openai/agents-core 0.19.0 release notes, published on October 5, 2026, list this change:2

bind conditional tool approvals to isolated normalized execution input in core and Realtime, re-evaluate current policies on durable approval resumes, reject uncopyable normalized values before conditional approval, and preserve invalid-input handling (#1914, #1915).

The fix itself was merged on September 21 in pull request #1948, which says it supersedes #1915.6 The npm registry shows no release between 0.18.0 (September 10) and 0.19.0 (October 5), so 0.19.0 is the first one that contains it.1

Issue #1914 was opened on September 14 and closed on September 21.5

Reproduce the needsApproval bug with a scripted model

You do not need an API key. The SDK ships a ScriptedModel in @openai/agents/testing that replays model responses you write.

Install each version in its own folder. Version 0.19.0 lists zod ^4.0.0 as a peer dependency, so I used Zod 4.6.5 for both.3

mkdir v018 v019
cd v018 && npm init -y && npm i @openai/agents@0.18.0 zod@4 && cd ..
cd v019 && npm init -y && npm i @openai/agents@0.19.0 zod@4 && cd ..

Save this as approval-test.mjs and copy it into both folders. It has four cases.

import { z } from 'zod';
import { Agent, run, tool, Usage, RunState } from '@openai/agents';
import { ScriptedModel, assistantMessage, functionCall, modelResponse } from '@openai/agents/testing';

const script = (name, args) => new ScriptedModel([
  modelResponse({ output: [functionCall(name, args, { callId: 'call_1' })], usage: new Usage() }),
  modelResponse({ output: [assistantMessage('done')], usage: new Usage() }),
]);
const short = (e) => `${e.constructor.name}: ${String(e.message).split('\n')[0].slice(0, 260)}`;

// 1. Optional field the model leaves out. Strict mode makes the model send null.
const emailParams = z.object({ to: z.string(), cc: z.array(z.string()).optional() });
async function nullCase(label, needs) {
  const seen = { needs: '-', exec: '-' };
  const sendEmail = tool({
    name: 'send_email', description: 'Send an email', parameters: emailParams,
    needsApproval: async (_ctx, input) => { seen.needs = JSON.stringify(input); return needs(input); },
    execute: async (input) => { seen.exec = JSON.stringify(input); return 'sent'; },
  });
  const agent = new Agent({ name: 'mailer', model: script('send_email', '{"to":"bob@example.com","cc":null}'), tools: [sendEmail] });
  let outcome;
  try {
    let r = await run(agent, 'send it');
    const asked = r.interruptions?.length ?? 0;
    if (asked) { r.state.approve(r.interruptions[0]); r = await run(agent, r.state); }
    outcome = `approvals asked=${asked}`;
  } catch (e) { outcome = `THROWS ${short(e)}`; }
  console.log(`${label}\n  needsApproval saw ${seen.needs} | execute saw ${seen.exec}\n  ${outcome}`);
}
await nullCase('A. guarded: cc !== undefined && cc.length > 0', ({ cc }) => cc !== undefined && cc.length > 0);
await nullCase('B. loose:   cc !== undefined', ({ cc }) => cc !== undefined);

// 2. Approve, park the run, resume it in a "new process" while the policy lookup is down.
async function resumeCase(policyDown) {
  const s = { down: false, needsCalls: 0, execCalls: 0 };
  const pay = tool({
    name: 'pay', description: 'Pay a vendor', parameters: z.object({ payee: z.string(), amount: z.number() }),
    needsApproval: async (_ctx, i) => { s.needsCalls++; if (s.down) throw new Error('policy service unavailable'); return i.amount > 100; },
    execute: async () => { s.execCalls++; return 'paid'; },
  });
  const agent = new Agent({ name: 'payer', model: script('pay', '{"payee":"acme","amount":500}'), tools: [pay] });
  const first = await run(agent, 'pay acme');
  first.state.approve(first.interruptions[0]);
  const saved = first.state.toString();
  s.down = policyDown;
  const before = s.needsCalls;
  try {
    await run(agent, await RunState.fromString(agent, saved));
    console.log(`C. resume, policy ${policyDown ? 'DOWN' : 'up  '}: needsApproval re-run=${s.needsCalls - before}x, execute ran=${s.execCalls}x`);
  } catch (e) { console.log(`C. resume, policy ${policyDown ? 'DOWN' : 'up  '}: THROWS ${short(e)}; needsApproval re-run=${s.needsCalls - before}x, execute ran=${s.execCalls}x`); }
}
await resumeCase(false);
await resumeCase(true);

// 3. A schema that returns something that is not plain data (a function).
const weird = z.object({ to: z.string() }).transform((v) => ({ ...v, audit: () => 'x' }));
let execCalls = 0;
const notify = tool({
  name: 'notify', description: 'Notify', parameters: weird,
  needsApproval: async () => false,
  execute: async () => { execCalls++; return 'ok'; },
});
try {
  await run(new Agent({ name: 'n', model: script('notify', '{"to":"bob"}'), tools: [notify] }), 'go');
  console.log(`D. non-plain parsed input: run completed, execute ran=${execCalls}x`);
} catch (e) { console.log(`D. non-plain parsed input: THROWS ${short(e)}\n   execute ran=${execCalls}x`); }

Run node approval-test.mjs in each folder. I ran each version three times and the output was identical every time.3

Results: 0.18.0 vs 0.19.0

Terminal output of approval-test.mjs run against @openai/agents 0.18.0 and 0.19.0

Figure 1. Real output of the script above on both versions (Node 22.22.0, Zod 4.6.5). Red lines are thrown errors. Long lines are wrapped for width.

Here is the same output in text form, 0.18.0 first:

A. guarded: cc !== undefined && cc.length > 0
  needsApproval saw {"to":"bob@example.com","cc":null} | execute saw -
  THROWS ToolCallError: Failed to run function tools: TypeError: Cannot read properties of null (reading 'length')
B. loose:   cc !== undefined
  needsApproval saw {"to":"bob@example.com","cc":null} | execute saw {"to":"bob@example.com"}
  approvals asked=1
C. resume, policy up  : needsApproval re-run=0x, execute ran=1x
C. resume, policy DOWN: needsApproval re-run=0x, execute ran=1x
D. non-plain parsed input: run completed, execute ran=1x

And 0.19.0:

A. guarded: cc !== undefined && cc.length > 0
  needsApproval saw {"to":"bob@example.com"} | execute saw {"to":"bob@example.com"}
  approvals asked=0
B. loose:   cc !== undefined
  needsApproval saw {"to":"bob@example.com"} | execute saw {"to":"bob@example.com"}
  approvals asked=0
C. resume, policy up  : needsApproval re-run=1x, execute ran=1x
C. resume, policy DOWN: THROWS ToolCallError: Failed to run function tools: Error: policy service unavailable; needsApproval re-run=1x, execute ran=0x
D. non-plain parsed input: THROWS ToolCallError: Failed to run function tools: FunctionToolApprovalInputError: Conditional tool approval requires copyable plain normalized input. Return plain data from the schema and resolve application objects inside execute.
   execute ran=0x

A guarded callback crashed the run on 0.18.0

Case A is a callback that looks correct for its declared type. Issue #1914 says the callback's input type for cc is string[] | undefined, so cc !== undefined && cc.length > 0 should be safe.5

On 0.18.0 it was not, because the callback saw cc: null. run() rejected with a ToolCallError wrapping the TypeError, and execute never ran.3

On 0.19.0 the callback saw the same object execute saw, with no cc key, and the run finished with no approval needed.3

A loose callback asked for approval it did not need

Case B is the other way a wrong input shows up. cc !== undefined is true for null, so on 0.18.0 the SDK paused the run and asked for approval on an email with no cc at all.3

On 0.19.0 the same callback returned false and the run went straight through. In both of my cases the wrong decision was an unneeded pause or a crash.

I did not build a callback where the old behavior let a risky call skip approval. I am not claiming one cannot exist.

Resumed runs now re-run needsApproval for approved calls

Case C parks an approved run, serializes it with state.toString(), and resumes it with RunState.fromString(). The SDK docs describe this pattern for runs that need to pause for a long time without keeping your server running.4

On 0.18.0, needsApproval ran zero times on resume. On 0.19.0 it ran once, even though the call was already approved.3

A call that was still waiting for a decision was different. In a separate check, needsApproval ran once on resume on both versions.3

That matches the release note about re-evaluating "current policies on durable approval resumes."2 In my test the outcome was the same when the policy was healthy: the approved call executed once.

The difference shows when the policy lookup fails. With the policy service down at resume time, 0.19.0 threw and execute ran zero times, while 0.18.0 executed the approved call.3

If your callback calls a database or a policy service, it now runs again at resume. Make it idempotent and decide what a failure should mean.

Non-plain input now throws before approval

Case D uses a Zod .transform() that returns an object holding a function. On 0.18.0 the run completed and execute ran once.3

On 0.19.0 the run threw FunctionToolApprovalInputError with the message "Conditional tool approval requires copyable plain normalized input," and execute ran zero times.3

The pull request text says outputs that cannot be inspected as independent plain data "now require manual approval."6 In my run with a callback that returns false, I got a thrown error, not an approval pause. I did not test other configurations.

What did not change

Three checks gave the same result on both versions. I ran them in small separate scripts that are not shown here.3

  • A .nullable() field that the model sets to null stayed null in both needsApproval and execute. So the fix removes the null that strict mode adds for .optional(), not every null.
  • A callback that mutates its input (I set input.amount = 1) did not change what execute saw on either version. The pull request says policy mutations cannot change execution input, and my test saw no leak on 0.18.0 either.6
  • A .optional() field with the key omitted entirely arrived as absent in both.

I also tried a .default("none") field with null input. execute did not run on either version, and I did not dig into why.

Checklist for a safe needsApproval callback

  • Check for both absent and null on 0.18.x. Use cc == null or (cc ?? []) until you can upgrade.
  • Upgrade to 0.19.0 and drop the workaround. In my tests the callback then saw the same input as execute.23
  • Return plain data from your schema. Resolve database rows and clients inside execute, not in a .transform().
  • Make the callback idempotent. It can run again when you resume a saved run.3
  • Decide what a policy outage means. On 0.19.0 a throwing callback blocks the resumed call. For something like payments, blocking is usually the safer failure.3
  • Store the saved state on your server. The docs say RunState.fromString() "does not authenticate the snapshot or the person submitting it."4

For the same approval pattern in another framework, see human-in-the-loop with the Claude Agent SDK in TypeScript. For the wider picture on keeping agent loops in check, see AI agent reliability with verification loops and guardrails.

What I did not test

  • I used a scripted model, not a live one. That the model sends null for an omitted optional field is the issue reporter's claim, and my evidence is the generated schema, which makes the field required and nullable.53
  • I tested plain function tools only. The release notes say the change also covers Realtime tools, and I did not run those or agent.asTool().2
  • I used one Node version (22.22.0) and one Zod version (4.6.5).
  • I did not test streaming runs or the alwaysApprove and alwaysReject options.

Bottom line

On @openai/agents 0.18.0, needsApproval could see null where execute saw nothing, and a callback that trusted its types crashed the run. On 0.19.0 both see the same normalized input.

The upgrade also moves policy checks into the resume path. Check that your callback is idempotent and fails the way you want when a lookup is down.

Footnotes

  1. npm registry metadata for @openai/agents (npm view @openai/agents time), read October 7, 2026: version 0.18.0 published 2026-09-10 and 0.19.0 published 2026-10-05, with no release in between. Package page: @openai/agents on npm. ↩ ↩2 ↩3

  2. openai/openai-agents-js GitHub releases, @openai/agents-core@0.19.0, published 2026-10-05. The quoted line is change 39decd7 in the "Minor Changes" list. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  3. Author's measurement, October 7, 2026: Node 22.22.0 on Linux x86_64, @openai/agents 0.18.0 and 0.19.0 (each with the matching @openai/agents-core), Zod 4.6.5, ScriptedModel from @openai/agents/testing. The 0.19.0 package declares zod ^4.0.0 as a peer dependency. The published script was extracted from this post and run unchanged in fresh folders. Each version was run three times with identical output. The schema printout, the undecided-resume check and the three unchanged-behavior checks were separate small scripts run on both versions with the same setup. The schema printout used tool() with z.array(z.string()).optional() and showed strict: true with cc in required. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25

  4. OpenAI Agents SDK for JavaScript, "Human in the loop" guide. Quotes taken on October 7, 2026 from the guide's source file on the main branch: human-in-the-loop.mdx. The main branch can be ahead of the 0.19.0 package. ↩ ↩2 ↩3 ↩4 ↩5

  5. openai/openai-agents-js, issue #1914, "needsApproval gets null for omitted optional fields on Zod and strict JSON schema tools", opened 2026-09-14 and closed 2026-09-21. The statement that the model sends null when it does not set the field is from the issue text. ↩ ↩2 ↩3 ↩4 ↩5

  6. openai/openai-agents-js, pull request #1948, "fix: align conditional tool approvals with normalized execution input", merged 2026-09-21 as commit 39decd7. The description says it supersedes #1915, which was closed without merging. ↩ ↩2 ↩3 ↩4 ↩5

  7. OpenAI Agents SDK for JavaScript, "Tools" guide, parameters table: "Validation schemas automatically enable strict mode." Read from the source file tools.mdx on main, October 7, 2026. ↩

Frequently Asked Questions

The docs say that when needsApproval is a function, the SDK calls it only after the tool arguments have parsed into an inspectable object.4 In 0.18.0 that object could contain null for omitted optional fields, and in 0.19.0 it matches what execute gets.3