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

> Learn how the @needle.tool decorator converts Python functions to JSON schemas by analyzing type hints docstrings and annotations. Simplify your code generation.

- Repository: [Cactus Compute, Inc./needle](https://github.com/cactus-compute/needle)
- Tags: how-to-guide
- Published: 2026-08-16

---

**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`](https://github.com/cactus-compute/needle/blob/main/needle/agent/tools.py) at lines 62–65, it simply invokes `build_schema()` and attaches the result to the function object:

```python
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`](https://github.com/cactus-compute/needle/blob/main/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:

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

```python
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)

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

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

```python
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

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

```json
{
  "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`](https://github.com/cactus-compute/needle/blob/main/needle/agent/tools.py) | Core implementation of `@tool`, `build_schema()`, and all helper functions |
| [`tests/test_tools.py`](https://github.com/cactus-compute/needle/blob/main/tests/test_tools.py) | Unit tests verifying schema correctness and decorator behavior |
| [`needle/__init__.py`](https://github.com/cactus-compute/needle/blob/main/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.