How Tool Schemas Are Built from Python Function Signatures in Needle 2

Needle 2 automatically converts annotated Python functions into JSON-compatible tool schemas that LLMs can invoke, using typing.get_type_hints, recursive type mapping, and optional Field constraints.

The cactus-compute/needle library eliminates manual schema writing by inspecting function signatures at runtime. This article explains the complete pipeline—from type hint resolution to final JSON assembly—based on the actual implementation in needle/agent/tools.py.

Overview of the Schema Building Pipeline

The core transformation happens in build_schema() at lines 17-46 of needle/agent/tools.py. The function takes any callable and returns a dictionary matching the OpenAI tool-calling format.

Here is the complete flow:

  1. Extract documentation from the docstring
  2. Resolve type hints with typing.get_type_hints
  3. Map Python types to JSON Schema primitives
  4. Detect optional parameters (union with None)
  5. Apply Field constraints for validation rules
  6. Assemble properties and required fields
  7. Produce the final tool object

Step 1: Collecting Documentation with _parse_doc

Before types are processed, Needle extracts human-readable descriptions. The _parse_doc function at lines 99-112 parses the function's docstring to obtain:

  • A short function description (first line)
  • Per-argument descriptions from subsequent sections
from needle.agent.tools import _parse_doc

def example(a: int, b: str):
    """Do something.
    
    Args:
        a: First number.
        b: Second value.
    """
    pass

print(_parse_doc(example))

# {'description': 'Do something.', 'args': {'a': 'First number.', 'b': 'Second value.'}}

Step 2: Resolving Type Hints

At line 17, build_schema calls typing.get_type_hints(fn, include_extras=True) to get fully resolved annotations. This handles forward references, imported types, and generic specializations.

If resolution fails, Needle falls back to an empty dict (lines 19-20), allowing the function to proceed with minimal type information.


# From needle/agent/tools.py lines 17-20

try:
    hints = typing.get_type_hints(fn, include_extras=True)
except Exception:
    hints = {}

Step 3: Mapping Python Types to JSON Schema

The _json_type function (lines 57-86) performs recursive type-to-schema translation. It handles:

Python Type JSON Schema Output
int, float, str, bool "integer", "number", "string", "boolean"
enum.Enum {"enum": [...], "type": "<inferred>"}
typing.Literal["a", "b"] {"enum": ["a", "b"], "type": "<inferred>"}
list[T], typing.List[T] {"type": "array", "items": <schema(T)>}
dict, typing.Dict {"type": "object"}
Optional[T], T | None Processed as nullable (see Step 4)
Fallback "string"

# Simplified excerpt from _json_type implementation

_JSON_TYPES = {
    int: "integer",
    float: "number", 
    str: "string",
    bool: "boolean",
}

def _json_type(annotation):
    # Direct primitive mapping

    if annotation in _JSON_TYPES:
        return {"type": _JSON_TYPES[annotation]}
    
    # Enum handling

    if isinstance(annotation, enum.EnumMeta):
        values = [item.value for item in annotation]
        # Infer type from first value...

    
    # Literal handling

    if typing.get_origin(annotation) is typing.Literal:
        values = typing.get_args(annotation)
        # Build enum with inferred type...

    
    # List/array handling

    origin = typing.get_origin(annotation)
    if origin in (list, List):
        item_type = typing.get_args(annotation)[0]
        return {"type": "array", "items": _json_type(item_type)}
    
    # Default fallback

    return {"type": "string"}

Step 4: Detecting Optional Parameters

Needle distinguishes required from optional arguments using _is_optional at lines 52-55. A parameter is optional if its annotation is a Union containing NoneType:


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

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

This detection affects whether a field appears in the required array (Step 6).

Step 5: Applying Field Constraints

For fine-grained control, Needle provides a Field class (lines 18-49) that carries validation metadata. When a parameter default is a Field instance, its apply method merges constraints into the schema.

Supported constraints include:

  • description – Override or augment docstring text
  • ge, le, gt, lt – Numeric bounds (minimum, maximum)
  • enum – Restrict to specific values
  • pattern – Regular expression for strings
from needle import Field

def configure(
    temp: int = Field(description="Target temperature", ge=0, le=100),
    mode: str = Field(enum=["heat", "cool", "auto"])
):
    """Set thermostat configuration."""
    pass

The resulting schema includes:

{
  "temp": {
    "type": "integer",
    "description": "Target temperature",
    "minimum": 0,
    "maximum": 100
  },
  "mode": {
    "type": "string",
    "enum": ["heat", "cool", "auto"]
  }
}

Step 6: Assembling Properties and Required Fields

The parameter loop at lines 23-39 iterates over inspect.signature(fn).parameters, skipping:

  • self, cls (bound method markers)
  • *args, **kwargs (variable arguments)

For each remaining parameter, Needle:

  1. Generates base schema via _json_type
  2. Adds description from docstring if available
  3. Merges Field constraints via Field.apply
  4. Determines if required: not required if it has a default value, a Field with default, or _is_optional returns True

# Conceptual flow from lines 23-39

properties = {}
required = []

for name, param in inspect.signature(fn).parameters.items():
    if name in ("self", "cls") or param.kind in (VAR_POSITIONAL, VAR_KEYWORD):
        continue
    
    # Build schema for this parameter

    schema = _json_type(hints.get(name, str))
    
    # Add description from docstring

    if name in doc_args:
        schema["description"] = doc_args[name]
    
    # Apply Field constraints

    if isinstance(param.default, Field):
        param.default.apply(schema)
    
    # Determine required status

    has_default = (param.default is not inspect.Parameter.empty 
                   and not isinstance(param.default, Field))
    is_field_with_default = isinstance(param.default, Field) and param.default.default is not ...  # noqa: E501

    optional_type = _is_optional(hints.get(name))
    
    if not (has_default or is_field_with_default or optional_type):
        required.append(name)
    
    properties[name] = schema

Step 7: Creating the Final Tool Object

Lines 40-46 construct the complete tool schema:


# From needle/agent/tools.py lines 40-46

result = {
    "name": fn.__name__,
    "description": description,
    "parameters": {
        "type": "object",
        "properties": properties,
    },
}
if required:
    result["parameters"]["required"] = required
return result

The structure matches the OpenAI function-calling specification, enabling direct use with LLM APIs.

Using the @tool Decorator

For convenience, the @tool decorator (lines 68-70) attaches the generated schema as _needle_tool:

from needle import tool, Field

@tool
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

# Access the schema

print(add._needle_tool)

Output:

{
  "name": "add",
  "description": "Add two numbers.",
  "parameters": {
    "type": "object",
    "properties": {
      "a": {"type": "integer"},
      "b": {"type": "integer"}
    },
    "required": ["a", "b"]
  }
}

Pydantic Model Support

For complex structured data, pydantic_schema at lines 55-65 converts Pydantic models to tool schemas. It extracts properties and required from model_json_schema() (or the legacy schema() method):

import pydantic
from needle.agent.tools import pydantic_schema

class WeatherQuery(pydantic.BaseModel):
    """Weather lookup parameters."""
    city: str
    units: str = "metric"  # Optional with default

print(pydantic_schema(WeatherQuery))

Output:

{
  "name": "WeatherQuery",
  "description": "Weather lookup parameters.",
  "parameters": {
    "type": "object",
    "properties": {
      "city": {"type": "string"},
      "units": {"type": "string"}
    },
    "required": ["city"]
  }
}

Complete Working Example

from typing import Literal
from needle import tool, Field
import enum

class Priority(enum.Enum):
    HIGH = "high"
    MEDIUM = "medium"
    LOW = "low"

@tool
def create_task(
    title: str,
    priority: Priority = Priority.MEDIUM,
    tags: list[str] = Field(default_factory=list, description="Categorization labels"),
    assignee: str | None = None  # Optional via union

) -> str:
    """Create a new project task."""
    return f"Created: {title}"

print(create_task._needle_tool)

Generated schema:

{
  "name": "create_task",
  "description": "Create a new project task.",
  "parameters": {
    "type": "object",
    "properties": {
      "title": {"type": "string"},
      "priority": {
        "enum": ["high", "medium", "low"],
        "type": "string"
      },
      "tags": {
        "type": "array",
        "items": {"type": "string"},
        "description": "Categorization labels"
      },
      "assignee": {"type": "string"}
    },
    "required": ["title"]
  }
}

Summary

  • build_schema in needle/agent/tools.py is the central function that converts Python callables to LLM-compatible tool schemas.
  • Type resolution uses typing.get_type_hints(..., include_extras=True) for accurate annotation extraction.
  • _json_type recursively maps Python types—including generics, enums, and literals—to JSON Schema fragments.
  • _is_optional identifies nullable unions to determine required vs. optional parameters.
  • Field instances attach validation constraints like numeric ranges and enum restrictions.
  • The @tool decorator automates schema attachment via the _needle_tool attribute.
  • pydantic_schema enables reusing Pydantic models as tool parameter definitions.

Frequently Asked Questions

What happens if my function has no type annotations?

Needle falls back to str for all parameters. The typing.get_type_hints call catches exceptions and returns an empty dict, causing _json_type to use its default "string" type for every argument.

Can I use standard libraries like datetime or Path in type hints?

Yes, but non-mapped types fall back to "string". For specialized JSON representations, use Field with a description clarifying the expected format, or wrap values in Pydantic models with custom validators.

How does Needle handle *args and **kwargs?

These are explicitly skipped in the parameter loop (lines 27-28). The generated schema only includes named, type-annotated parameters that an LLM can reasonably populate. Variable arguments are incompatible with structured tool calling.

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 →