How Needle Handles Optional Fields in Tool Calls: A Complete Guide to JSON-Schema Generation

Needle automatically excludes parameters annotated with Optional[T] or T | None from the "required" array in generated JSON schemas, regardless of whether a default value is provided.

Needle is a lightweight framework for building LLM-native tools that converts Python function signatures into JSON-Schema definitions usable by function-calling models. Understanding how Needle handles optional fields in tool calls is critical for building correct, model-compatible tool definitions. This article examines the implementation details in the Needle source code, including the detection logic, schema generation rules, and verification through test cases.

How Needle Detects Optional Type Annotations

The foundation of optional field handling begins with type inspection. In needle/agent/tools.py, the private helper _is_optional determines whether a parameter's annotation should be treated as optional.

This function recognizes both traditional and modern Python union syntaxes:

  • typing.Optional[T] (equivalent to Union[T, None])
  • T | None (PEP 604 union syntax, Python 3.10+)

The implementation checks two conditions: whether the type's origin is a union type, and whether None appears among the union's arguments.


# From needle/agent/tools.py lines 52-55

def _is_optional(annotation):
    origin = get_origin(annotation)
    if origin is Union:
        return None in get_args(annotation)
    return False

This detection runs before schema construction, ensuring that the optionality information is available when building the final JSON-Schema document.

The Schema Building Logic for Required vs. Optional Parameters

Inside build_schema (also in needle/agent/tools.py), Needle evaluates each parameter to determine membership in the "required" array. The logic follows a precise decision tree at lines 34-42 and 133-139:

has_default = param.default is not param.empty and not isinstance(param.default, Field)
if field and field.has_default():
    has_default = True

if not has_default and not _is_optional(annotation):
    required.append(name)

A parameter becomes optional in the schema if either:

  • It has a default value (including None or a Field with a default)
  • Its type annotation passes the _is_optional check (e.g., Optional[str] or str | None)

A parameter becomes required only when:

  • It lacks a default value AND
  • Its annotation is not detected as optional

This dual-check system means you can declare def func(x: Optional[int]) without providing a default value, and Needle will still treat x as optional in the generated schema.

Code Examples: Optional Field Handling in Practice

Optional Annotation Without Default Value

from needle import tool
from typing import Optional

@tool
def get_weather(city: str, units: Optional[str] = None):
    """Fetch weather data for a location."""
    return f"Weather for {city} in {units or 'metric'} units"

# Schema inspection

print(get_weather._needle_tool["parameters"]["required"])

# Output: ['city']

print(get_weather._needle_tool["parameters"]["properties"].keys())

# Output: dict_keys(['city', 'units'])

Even though units defaults to None in this example, the same behavior occurs without any default:

@tool
def search_docs(query: str, max_results: Optional[int]):
    """Search documentation with optional result limit."""
    ...
    

# 'max_results' is still omitted from required due to Optional annotation

PEP 604 Union Syntax Support

Needle equally supports Python 3.10+ pipe union syntax:

@tool
def add_numbers(a: int, b: int | None = None):
    """Add two numbers, with optional second operand."""
    if b is None:
        b = 0
    return a + b

print(add_numbers._needle_tool["parameters"]["required"])

# Output: ['a']

The b parameter is excluded from "required" because int | None passes the _is_optional check.

Interaction with Field Defaults

When combining Field metadata with optional annotations, Needle respects explicit defaults:

from needle import tool, Field
from typing import Optional

@tool
def configure_timeout(
    host: str,
    timeout: Optional[int] = Field(default=30, description="Seconds to wait")
):
    """Configure connection with custom timeout."""
    ...

# Both parameters are optional; 'timeout' has explicit Field default

print(configure_timeout._needle_tool["parameters"].get("required"))

# Output: None or [] depending on JSON serialization

Test Coverage and Verification

The Needle test suite in tests/test_tools.py contains explicit tests validating this behavior:

  • test_optional_annotation_not_required (lines 54-60): Confirms that typing.Optional[int] parameters are excluded from the required array
  • test_pep604_none_union_not_required (lines 62-70): Verifies identical behavior for T | None syntax

These tests ensure that schema generation remains consistent across Python versions and type annotation styles.

Summary

  • Detection mechanism: The _is_optional helper in needle/agent/tools.py recognizes Optional[T], Union[T, None], and T | None by checking for None in union arguments
  • Required array construction: Parameters are added to "required" only when they lack both a default value and an optional type annotation
  • Default value handling: Explicit defaults (including None and Field defaults) always mark parameters as optional
  • Modern syntax support: PEP 604 union syntax works identically to typing.Optional
  • Verified behavior: Unit tests in tests/test_tools.py prevent regressions in optional field detection

Frequently Asked Questions

Does Needle require default values for optional parameters?

No. Needle treats any parameter annotated with Optional[T] or T | None as optional in the generated schema, even without an explicit default value. The parameter will be omitted from the "required" array based solely on its type annotation.

What types of optional annotations does Needle support?

Needle supports both typing.Optional[T] (and its equivalent Union[T, None]) and the newer T | None syntax introduced in PEP 604. The _is_optional function in needle/agent/tools.py handles both cases uniformly by inspecting the type's origin and arguments.

How does Needle handle Field defaults with optional types?

When a parameter uses Field(default=...), Needle checks field.has_default() in addition to inspecting the raw parameter default. This means Optional[int] = Field(default=5) correctly marks the parameter as optional, with the schema reflecting both the type constraints and the default value.

Where can I find the implementation details?

The core logic resides in needle/agent/tools.py, specifically the build_schema function (lines 34-42 and 133-139) and the _is_optional helper (lines 52-55). The needle/__init__.py file exports tool and Field for public use, while tests/test_tools.py contains verification tests including test_optional_annotation_not_required and test_pep604_none_union_not_required.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →