How @tool turns a typed Python function into a tool schema, which types are supported, how to describe parameters, and when to use parameters= or strict=.
Tools
A tool is a plain Python function decorated with @tool(). Dendrux reads the signature, type hints, and docstring once, at decoration time, and stores the result on a ToolDef. The function stays callable as normal.
from typing import Literal
from dendrux import tool
@tool()
async def show(kind: Literal["chart", "table"], title: str | None = None) -> str:
"""Render a chart or table in the UI.
Args:
kind: Which widget to render.
title: Optional heading shown above the widget.
"""
return f"rendered {kind}"The tool's description is the full docstring. The arguments schema is generated from the type hints.
Decorator options
Supported parameter types
Nested types compose. For example, list[Node] where Node is a BaseModel becomes an array of typed objects.
A parameter is required when it has no default and is not Optional. Defaults must be JSON-serializable and are emitted as "default".
Some types are rejected at decoration time with a TypeError that names the parameter:
- Unions other than
T | None, for examplestr | int LiteralorEnumvalues of mixed types, for exampleLiteral["a", 1]dictwith non-strkeys- Recursive models, dataclasses, or TypedDicts, because they cannot be inlined
- Any other custom class
For any of these, pass the schema yourself with parameters=.
Arguments arrive as JSON-decoded values. A
BaseModel, dataclass, orTypedDictparameter receives adict, and anEnumparameter receives the raw value. Validate the input yourself, for example withNode.model_validate(raw)orUnit(raw).
Malformed arguments never reach your tool. If the model's arguments are not valid JSON, the call is rejected before parameter validation with
code: "invalid_json". The model sees the parse error and retries; the run does not fail, and the raw arguments are never persisted. See Tool argument validation.
Parameter descriptions
There are two ways to describe a parameter. If both are present for the same parameter, Annotated wins.
Annotated metadata: use either a pydantic Field(description=...) or a plain string.
from typing import Annotated
from pydantic import Field
@tool()
async def search(
query: Annotated[str, Field(description="Full-text search query")],
limit: Annotated[int, "Maximum number of results"] = 10,
) -> list[str]:
"""Search the catalog."""
...Google-style docstring Args: section: Dendrux reads lines of the form name: text and name (type): text. Indented continuation lines are joined to the line above. Entries for names that are not parameters are ignored. The docstring itself stays unchanged as the tool's description.
Explicit schema: parameters=
When the generated schema does not fit, pass your own JSON Schema. Generation is skipped entirely and the schema is stored as given. The root must be a dict with "type": "object".
@tool(parameters={
"type": "object",
"properties": {"value": {"anyOf": [{"type": "string"}, {"type": "number"}]}},
"required": ["value"],
})
async def record(value) -> str:
"""Record a value."""
...Strict mode
@tool(strict=True) opts the tool into OpenAI strict function calling. With strict mode on, OpenAI guarantees that the arguments match the schema.
The schema is normalized once, at decoration time, and stored on ToolDef.parameters, so every provider sees the same schema:
- Every object gets
additionalProperties: false. - Every property becomes required.
Optionalparameters are already nullable, so the model can still passnull. - A parameter that has a non-
Nonedefault, such aslimit: int = 10, also becomes required. The model must then supply a value, and the default appears only as a"default"hint in the schema.
On the wire:
OpenAI strict mode requires every object to declare its properties. If a strict tool has a free-form dict or dict[str, T] parameter, the decorator raises TypeError. Use a BaseModel, dataclass, or TypedDict instead.
strict=True combines with parameters=: your explicit schema is normalized the same way.
MCP tools
Tools discovered from MCP servers do not go through @tool. Their server-provided inputSchema is passed to the model unchanged, and strict mode does not apply to them.