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

> Discover how Needle 2 builds tool schemas from Python function signatures using typing get_type_hints and Field constraints for LLM invocation.

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

---

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

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

```python

# 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"` |

```python

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

```python

# 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

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

```json
{
  "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`

```python

# 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:

```python

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

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

```json
{
  "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):

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

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

```

## Complete Working Example

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

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