How the `@needle.tool` Decorator Converts Python Functions to JSON Schemas

The @needle.tool decorator generates JSON schemas from Python functions by inspecting type hints, parsing docstrings, and mapping annotations to JSON-Schema types via the build_schema() function.

The cactus-compute/needle repository provides a lightweight framework for building AI agents with tool-calling capabilities. Central to this is the @needle.tool decorator, which automatically transforms any Python function into a standards-compliant JSON schema. This article examines the complete implementation, from the thin decorator wrapper to the core schema-generation pipeline.

The Decorator: A Minimal Attachment Point

The public-facing @tool decorator is intentionally minimal. Located in needle/agent/tools.py at lines 62–65, it simply invokes build_schema() and attaches the result to the function object:

def tool(fn):
    fn._needle_tool = build_schema(fn)   # Generates the JSON schema

    return fn

The decorated function remains fully callable—only the private _needle_tool attribute is added. This design preserves the original function's behavior while making schema metadata accessible to the Needle runtime.

The build_schema() Pipeline

All schema generation logic resides in build_schema(), spanning lines 11–40 of needle/agent/tools.py. The implementation follows a six-step pipeline that transforms Python introspection data into a JSON-Schema dictionary.

Step 1: Extract Function Signature

The pipeline begins with Python's inspect module to retrieve parameter information:

signature = inspect.signature(fn)   # Line 11

This captures parameter names, default values, and kind (positional, keyword-only, etc.).

Step 2: Resolve Type Hints

Type annotations are resolved using typing.get_type_hints() with include_extras=True to preserve Annotated metadata:

hints = typing.get_type_hints(fn, include_extras=True)   # Lines 13–15

If introspection fails, the code gracefully falls back to an empty dictionary.

Step 3: Parse Docstring Documentation

The function's docstring is processed to extract:

  • A top-level description (summary before Args:)
  • Per-argument documentation (parsed from Args: section)
description, arg_docs = _parse_doc(fn.__doc__)   # Line 16

This connects human-readable documentation directly to the generated schema.

Step 4: Convert Parameters to JSON-Schema Types

For each non-special parameter (self, cls, *args, **kwargs are skipped), the code:

  • Determines the effective annotation (type hint or fallback)
  • Invokes _json_type() to map Python types to JSON-Schema fragments
  • Incorporates docstring descriptions
  • Applies Field metadata for defaults and constraints

The core loop begins at line 18, with type conversion in _json_type() (lines 56–81). Supported mappings include:

Python Type JSON-Schema Output
int {"type": "integer"}
float {"type": "number"}
str {"type": "string"}
bool {"type": "boolean"}
list[T] {"type": "array", "items": ...}
Literal[...] {"enum": [...]}
Enum subclass {"enum": [...], "type": "string"}
Optional[T] T schema with T not required
datetime {"type": "string"}

Step 5: Determine Required Parameters

A parameter is required unless one of three conditions is met:

if not has_default and not _is_optional(annotation):
    required.append(name)   # Lines 32–34

Parameters are optional when they have:

  • A default value in the signature
  • A Field(default=...) specification
  • An Optional[T] annotation (detected via _is_optional())

Step 6: Assemble Final Schema

The final dictionary combines all collected metadata:

out = {"name": fn.__name__, "parameters": parameters}   # Line 35

If docstring description exists, it's added at the top level. The parameters object contains:

  • type: "object"
  • properties: Map of parameter names to their schemas
  • required: Array of required parameter names

Critical Helper Functions

_json_type() — Recursive Type Mapping

Located at lines 56–81, this function recursively processes complex annotations:

  • Annotated[T, Field(...)]: Extracts base type and Field metadata
  • Union[T, U, ...]/T \| U: Creates anyOf schemas
  • Pydantic models: Delegates to pydantic_schema() for full model serialization

_field_of() — Metadata Extraction

Detects Field instances from Annotated wrappers or default values, enabling:

  • Custom descriptions
  • Numeric constraints (ge, le, gt, lt)
  • String constraints (min_length, max_length, pattern)
  • Enum constraints
  • Constant values (const)

_is_optional() — Optional Detection

Identifies Optional[T] (equivalent to Union[T, None]) to prevent unnecessary required entries.

Complete Working Example

from needle import tool, Field
from datetime import datetime
from enum import Enum

class Priority(Enum):
    HIGH = "high"
    LOW = "low"

@tool
def schedule_meeting(
    title: str,
    start: datetime,
    duration_min: int = Field(default=30, ge=5, le=180),
    priority: Priority = Priority.LOW,
) -> None:
    """Schedule a meeting.

    Args:
        title: Human-readable meeting title.
        start: When the meeting should begin.
        duration_min: Length in minutes (5–180). Default 30.
        priority: Priority level, defaults to LOW.
    """
    pass

# Inspect generated schema

import json
print(json.dumps(schedule_meeting._needle_tool, indent=2))

Generated output:

{
  "name": "schedule_meeting",
  "description": "Schedule a meeting.",
  "parameters": {
    "type": "object",
    "properties": {
      "title": {
        "type": "string",
        "description": "Human-readable meeting title."
      },
      "start": {
        "type": "string"
      },
      "duration_min": {
        "type": "integer",
        "default": 30,
        "description": "Length in minutes (5–180). Default 30.",
        "minimum": 5,
        "maximum": 180
      },
      "priority": {
        "type": "string",
        "enum": ["high", "low"],
        "default": "low"
      }
    },
    "required": ["title", "start"]
  }
}

Note how:

  • datetime maps to "string" (standard JSON representation)
  • Field constraints become minimum/maximum
  • Enum values populate enum array
  • Default values appear in schema
  • Optional detection makes duration_min and priority non-required

Key Source Files

File Purpose
needle/agent/tools.py Core implementation of @tool, build_schema(), and all helper functions
tests/test_tools.py Unit tests verifying schema correctness and decorator behavior
needle/__init__.py Public API export of tool symbol

Summary

  • The @needle.tool decorator is a thin wrapper that calls build_schema() and stores the result in _needle_tool
  • build_schema() implements a six-step pipeline: signature inspection, type hint resolution, docstring parsing, parameter conversion, required/optional determination, and final assembly
  • Type mapping is handled recursively by _json_type(), supporting generics, unions, literals, enums, and Pydantic models
  • Field metadata and Annotated types enable fine-grained schema customization
  • The generated schemas follow JSON Schema standards for compatibility with OpenAI, Anthropic, and other tool-calling APIs

Frequently Asked Questions

What Python versions does @needle.tool support?

The implementation relies on typing.get_type_hints() with include_extras=True, which requires Python 3.9+. The code uses modern annotation syntax (list[int] instead of List[int]) and union operator (\|) compatibility patterns for newer Python versions.

Can I use Pydantic models as parameter types?

Yes. When build_schema() encounters a Pydantic BaseModel annotation, it calls pydantic_schema() to extract the model's own JSON schema. This enables nested, validated data structures with Pydantic's full validation and serialization capabilities.

How does @needle.tool handle *args and **kwargs?

Special parameters are explicitly skipped during schema generation. The code checks param.kind and continues the loop for VAR_POSITIONAL (*args) and VAR_KEYWORD (**kwargs) parameters. This ensures clean schemas without variadic parameter complexity that most JSON Schema consumers cannot handle.

Is the original function modified or wrapped?

No. The decorator returns the original function object unchanged except for the added _needle_tool attribute. There is no function wrapping, so performance characteristics, inspect behavior, and pickling remain unaffected.

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 →