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
Fieldmetadata 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 schemasrequired: 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 metadataUnion[T, U, ...]/T \| U: CreatesanyOfschemas- 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:
datetimemaps to"string"(standard JSON representation)Fieldconstraints becomeminimum/maximum- Enum values populate
enumarray - Default values appear in schema
Optionaldetection makesduration_minandprioritynon-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.tooldecorator is a thin wrapper that callsbuild_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 Fieldmetadata andAnnotatedtypes 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →