---
title: 'OpenAI Agents SDK needsApproval: Null Bug Tested'
description: I ran OpenAI Agents JS 0.18.0 and 0.19.0 side by side. needsApproval got null for omitted optional fields until 0.19.0, and resumed runs now re-check it.
url: https://nerdleveltech.com/openai-agents-js-needsapproval-null-optional-tested
language: en
date: '2026-10-07'
category: ai-ml
tags:
  - openai agents sdk
  - needsapproval
  - human in the loop
  - tool approval
  - typescript
  - ai agents
markdown_url: https://nerdleveltech.com/openai-agents-js-needsapproval-null-optional-tested.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/openai-agents-js-needsapproval-null-optional-tested'
terms: https://nerdleveltech.com/llms.txt
---

# 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.[^1][^2]

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.[^7] 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.[^2][^7]

## 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.[^5]

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.[^3][^7] On 0.19.0 both see the same normalized input.[^4][^7]

## 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.[^6]

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`.[^7]

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.[^3][^7]

## 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.[^4] 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.[^3]

## 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.[^7]

```bash
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.

```js
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.[^7]

## 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](https://nerdleveltech.com/images/openai-agents-needsapproval-0-18-vs-0-19.png)

*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:

```text
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:

```text
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.[^3]

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.[^7]

On 0.19.0 the callback saw the same object `execute` saw, with no `cc` key, and the run finished with no approval needed.[^7]

## 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.[^7]

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.[^5]

On 0.18.0, `needsApproval` ran zero times on resume. On 0.19.0 it ran once, even though the call was already approved.[^7]

A call that was still waiting for a decision was different. In a separate check, `needsApproval` ran once on resume on both versions.[^7]

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.[^7]

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.[^7]

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

The pull request text says outputs that cannot be inspected as independent plain data "now require manual approval."[^4] 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.[^7]

- 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.[^4]
- 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`.[^2][^7]
- **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.[^7]
- **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.[^7]
- **Store the saved state on your server.** The docs say `RunState.fromString()` "does not authenticate the snapshot or the person submitting it."[^5]

For the same approval pattern in another framework, see [human-in-the-loop with the Claude Agent SDK in TypeScript](https://nerdleveltech.com/human-in-the-loop-claude-agent-sdk-typescript-tutorial). For the wider picture on keeping agent loops in check, see [AI agent reliability with verification loops and guardrails](https://nerdleveltech.com/ai-agent-reliability-verification-loops-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.[^3][^7]
- 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.

## FAQ

### What does needsApproval receive in the OpenAI Agents SDK?

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

### Which version fixes the needsApproval null bug?

`@openai/agents` 0.19.0, published October 5, 2026, which includes `@openai/agents-core` 0.19.0.[^1][^2] The fix was merged on September 21 in pull request #1948.[^4]

### Why did my needsApproval callback throw a TypeError on null?

With a Zod `.optional()` field in strict mode, 0.18.0 passed the raw `null` the model sent. A check like `cc.length` then threw, and `run()` rejected with a `ToolCallError`.[^7]

### Does the SDK re-run needsApproval when I resume a saved run?

On 0.19.0, yes. In my test it ran once on resume, even for an already approved call, and 0.18.0 did not run it at all.[^7]

### Can I resume a run from a RunState the user sends me?

The docs say no, not without verification. `RunState.fromString()` restores state without authenticating the snapshot or the person submitting it.[^5]

### Do I need an API key to test this?

No. `ScriptedModel`, `modelResponse` and `functionCall` come from `@openai/agents/testing`, and I ran every case without one.[^7]

The Python SDK had its own upgrade change in 0.23.1, covered in [OpenAI Agents SDK 0.23: Tool Error Redaction Tested](https://nerdleveltech.com/openai-agents-python-tool-error-redaction-tested).

The next release, 0.20.0, changed how resumed approvals count toward `maxTurns`. See [OpenAI Agents SDK maxTurns After Approval, Tested](https://nerdleveltech.com/openai-agents-js-maxturns-approval-resume-tested).

## 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.

[^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](https://www.npmjs.com/package/@openai/agents).
[^2]: openai/openai-agents-js GitHub releases, [`@openai/agents-core@0.19.0`](https://github.com/openai/openai-agents-js/releases/tag/%40openai%2Fagents-core%400.19.0), published 2026-10-05. The quoted line is change 39decd7 in the "Minor Changes" list.
[^3]: openai/openai-agents-js, [issue #1914, "needsApproval gets null for omitted optional fields on Zod and strict JSON schema tools"](https://github.com/openai/openai-agents-js/issues/1914), 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.
[^4]: openai/openai-agents-js, [pull request #1948, "fix: align conditional tool approvals with normalized execution input"](https://github.com/openai/openai-agents-js/pull/1948), merged 2026-09-21 as commit 39decd7. The description says it supersedes [#1915](https://github.com/openai/openai-agents-js/pull/1915), which was closed without merging.
[^5]: OpenAI Agents SDK for JavaScript, ["Human in the loop" guide](https://openai.github.io/openai-agents-js/guides/human-in-the-loop/). Quotes taken on October 7, 2026 from the guide's source file on the main branch: [`human-in-the-loop.mdx`](https://github.com/openai/openai-agents-js/blob/main/docs/src/content/docs/guides/human-in-the-loop.mdx). The main branch can be ahead of the 0.19.0 package.
[^6]: OpenAI Agents SDK for JavaScript, ["Tools" guide](https://openai.github.io/openai-agents-js/guides/tools/), parameters table: "Validation schemas automatically enable **strict** mode." Read from the source file [`tools.mdx`](https://github.com/openai/openai-agents-js/blob/main/docs/src/content/docs/guides/tools.mdx) on main, October 7, 2026.
[^7]: 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`.
