# How Needle Extracts Tool Schemas from Python Functions: A Deep Dive into `@tool` and `build_schema`

> Discover how Needle extracts tool schemas from Python functions using introspection and build_schema. Learn to generate OpenAI-compatible JSON schemas for your tools.

- Repository: [Cactus Compute, Inc./needle](https://github.com/cactus-compute/needle)
- Tags: deep-dive
- Published: 2026-08-26

---

**Needle extracts tool schemas by introspecting Python function signatures, type hints, docstrings, and `Field` metadata in [`needle/agent/tools.py`](https://github.com/cactus-compute/needle/blob/main/needle/agent/tools.py), producing OpenAI-compatible JSON schemas through the `build_schema()` helper.**

Needle is a lightweight Python library for structured extraction with LLMs. Its core capability is turning regular Python callables into **OpenAI-compatible tool schemas** without boilerplate. This article examines the exact mechanism in the `cactus-compute/needle` repository, walking through the four-step pipeline from decorated function to structured output.

## The `@tool` Decorator: Entry Point for Schema Extraction

When you apply the `@tool` decorator to a function, Needle immediately triggers schema construction. In [`needle/agent/tools.py`](https://github.com/cactus-compute/needle/blob/main/needle/agent/tools.py) lines 68-70, the decorator stores the schema directly on the function object:

```python
def tool(fn):
    fn._needle_tool = build_schema(fn)
    return fn

```

This allows the schema to be computed once at decoration time rather than repeatedly at runtime. The `tools=` argument in agent initialization uses the same `build_schema()` helper for undecorated functions.

## Step-by-Step Schema Construction in `build_schema()`

The `build_schema()` function orchestrates four distinct phases of introspection.

### 1. Signature and Type Hint Extraction

`build_schema()` uses `inspect.signature()` to enumerate parameters and `typing.get_type_hints()` to resolve forward references and complex annotations. This happens before any JSON-specific processing begins.

### 2. Type Mapping via `_json_type()`

For each parameter, `_json_type()` (lines 57-86) maps Python types to JSON Schema types:

| Python Type | JSON Schema Output |
|-------------|------------------|
| `int` | `{"type": "integer"}` |
| `float` | `{"type": "number"}` |
| `str` | `{"type": "string"}` |
| `bool` | `{"type": "boolean"}` |
| `Enum`, `Literal` | `{"type": "string", "enum": [...]}` |
| `list[T]` | `{"type": "array", "items": <schema for T>}` |
| `BaseModel` | Merged model schema via `pydantic_schema()` |

### 3. Docstring Parsing with `_parse_doc()`

The `_parse_doc()` helper (lines 99-112) extracts:

- **Summary**: First line of the docstring becomes the tool's `description`
- **Argument docs**: Google/NumPy-style `Args:` sections populate per-parameter `description` fields

### 4. `Field` Metadata and Constraint Application

Needle supports `typing.Annotated` for rich constraints:

```python
from typing import Annotated
from needle import Field, tool

@tool
def search(
    query: str,
    limit: Annotated[int, Field(description="Max results", ge=1, le=100)] = 10
):
    """Search the inventory."""
    ...

```

The `Field.apply()` method (lines 36-49) merges these constraints into the JSON schema, producing `minimum`, `maximum`, `enum`, or `description` fields as appropriate.

### 5. Required Field Detection

The `_is_optional()` helper determines membership in the `required` array (lines 136-138). An argument is **required** when it lacks both a default value and an `Optional`/`Union[..., None]` type.

## First-Class Pydantic Model Support

When `_json_type()` encounters a Pydantic `BaseModel`, it delegates to `pydantic_schema()` (lines 55-72). This extracts the model's JSON schema via `model_json_schema()` or `model.schema()`, then merges it into the tool's parameter structure.

```python
from pydantic import BaseModel
from needle import extract

class Invoice(BaseModel):
    """Extracted invoice data."""
    vendor: str
    amount: float
    date: str

# Schema is extracted automatically; result is validated against Invoice

invoice = extract("ACME Corp billed $500 on 2024-01-15", schema=Invoice)

```

## The Complete Schema Shape

The final dictionary matches OpenAI's function-calling specification exactly (lines 39-46):

```python
{
    "name": "function_name",
    "description": "Docstring summary.",
    "parameters": {
        "type": "object",
        "properties": {
            "arg1": {"type": "string", "description": "..."},
            ...
        },
        "required": ["arg1", ...]
    }
}

```

## Runtime Extraction: `needle.extract()`

The public `extract()` function in [`needle/__init__.py`](https://github.com/cactus-compute/needle/blob/main/needle/__init__.py) (lines 149-178) creates a temporary `Needle` agent that treats your schema as the sole available tool. The LLM generates JSON conforming to the schema, which Needle then validates into either a Pydantic instance or plain dict.

## Complete Working Example

```python
from typing import Annotated, Literal
from needle import tool, Field, extract

@tool
def get_weather(
    city: str,
    units: Annotated[
        Literal["celsius", "fahrenheit"],
        Field(description="Temperature unit")
    ] = "celsius"
) -> dict:
    """Fetch current weather conditions.

    Args:
        city: The city to query.
        units: Preferred temperature unit.
    """
    # Implementation omitted

    pass

# Inspect the generated schema

print(get_weather._needle_tool)

# Use in extraction pipeline

agent = Needle()
response = agent.extract(
    "What's the weather in Tokyo?",
    tools=[get_weather]
)

```

## Summary

- **`@tool` decorator** triggers immediate schema construction via `build_schema()` in [`needle/agent/tools.py`](https://github.com/cactus-compute/needle/blob/main/needle/agent/tools.py)
- **Type hints** map to JSON Schema through `_json_type()`, with special handling for Pydantic models
- **Docstrings** provide human-readable descriptions parsed by `_parse_doc()`
- **`Field` annotations** add constraints like ranges, enums, and custom descriptions
- **Required fields** are detected automatically from defaults and `Optional` types
- **`needle.extract()`** leverages the same machinery for one-shot structured extraction

## Frequently Asked Questions

### What Python versions does Needle's schema extraction support?

Needle relies on `typing.get_type_hints()` and `inspect.signature()`, requiring Python 3.8+. Forward references and `from __future__ import annotations` are fully supported through PEP 563 compatibility in the type hint resolution pipeline.

### Can I use Pydantic v1 and v2 models interchangeably?

Yes. The `pydantic_schema()` helper in [`needle/agent/tools.py`](https://github.com/cactus-compute/needle/blob/main/needle/agent/tools.py) detects the Pydantic version and calls `model_json_schema()` for v2 or `model.schema()` for v1, normalizing the output to a consistent dict structure before merging into the tool schema.

### How does Needle handle complex nested types like `list[BaseModel]`?

`_json_type()` recurses through container types. For `list[T]` or `dict[str, T]`, it extracts the item type's schema and places it under `items` or `additionalProperties`. If `T` is itself a Pydantic model, `pydantic_schema()` handles the nested extraction, producing fully specified JSON Schema structures.

### What happens if my function has no docstring?

The schema still generates successfully. The `description` field becomes an empty string, and parameter descriptions are omitted. Needle recommends adding docstrings for production use, as LLM tool selection accuracy depends on clear descriptions.