DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

Stop Writing Tool Schemas by Hand: tool-schema-from-fn

I hand-wrote JSON schemas for eleven tool functions.

This is not a brag. It was a mistake that took two days to fully surface.

The first sign was a ToolArgError. The model called search_records with sort_by set to "alphabetical". The actual parameter only accepted "asc" or "desc". The schema I had written listed the enum values. But somewhere between first draft and fourth edit, "alphabetical" had crept in as a placeholder and never left.

I fixed the schema. Same error on the next run. Looked again. The schema said "asc" and "desc". The function itself expected "ascending" and "descending". At some point I had renamed the values in the function without updating the schema.

Two iterations of ToolArgError, 40 minutes of debugging, and the root cause was a typo in a dict I had written by hand.

Multiply that by eleven tools. Add team members updating function signatures without touching schemas. Add new tools being added "quickly" because the schema format is obvious enough to eyeball. That pattern does not get better with time.

The Shape of the Fix

tool-schema-from-fn generates the schema from the function itself. The function signature is the source of truth. The schema is derived from it.

from tool_schema_from_fn import schema_for

def search_records(
    query: str,
    sort_by: Literal["asc", "desc"],
    limit: int = 10
) -> list[dict]:
    """Search records in the database.

    Args:
        query: The search query string.
        sort_by: Sort direction for results.
        limit: Maximum number of records to return.
    """
    ...

tool_schema = schema_for(search_records, provider="anthropic")
Enter fullscreen mode Exit fullscreen mode

That call returns a dict in the exact shape the Anthropic API expects:

{
    "name": "search_records",
    "description": "Search records in the database.",
    "input_schema": {
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "The search query string."
            },
            "sort_by": {
                "type": "string",
                "enum": ["asc", "desc"],
                "description": "Sort direction for results."
            },
            "limit": {
                "type": "integer",
                "description": "Maximum number of records to return."
            }
        },
        "required": ["query", "sort_by"]
    }
}
Enter fullscreen mode Exit fullscreen mode

limit has a default value so it is not in required. sort_by has a Literal annotation so it becomes an enum. The docstring provides descriptions. Nothing was repeated.

For OpenAI format, swap the provider argument:

tool_schema = schema_for(search_records, provider="openai")
Enter fullscreen mode Exit fullscreen mode

The output wraps in the OpenAI shape with "function" and "type": "function" at the top level.

What It Does NOT Do

Before going further, the limits matter:

  • It does not validate arguments at call time. Generating a schema and enforcing it are separate problems. Use agentvet for enforcement.
  • It does not handle nested Pydantic models. If your function signature takes a MyModel parameter, you need to handle the schema for that yourself or flatten it.
  • It does not parse NumPy or reStructuredText docstrings. Only Google-style Args: blocks are recognized.
  • It does not infer descriptions from variable names. If a parameter has no docstring entry, the description field is omitted from that property.

Inside the Lib: Literal to Enum Mapping

The most useful part of this library is also the most easily overlooked.

When you annotate a parameter as Literal["a", "b", "c"], you are telling Python that this parameter only accepts those three values. The type annotation already contains the valid values. The JSON Schema equivalent is "enum": ["a", "b", "c"].

Getting this right requires inspecting the annotation at schema-generation time, not at call time. The library uses typing.get_type_hints() to retrieve annotations, then checks whether each annotation is a Literal using typing.get_origin() and typing.get_args().

# Simplified version of the internal mapping
import typing

def _type_to_schema(annotation) -> dict:
    origin = typing.get_origin(annotation)
    args = typing.get_args(annotation)

    if origin is typing.Literal:
        return {"type": "string", "enum": list(args)}

    if origin is list:
        item_schema = _type_to_schema(args[0]) if args else {"type": "string"}
        return {"type": "array", "items": item_schema}

    if origin is dict:
        return {"type": "object"}

    # Union[T, None] is Optional[T]
    if origin is typing.Union and type(None) in args:
        non_none = [a for a in args if a is not type(None)]
        if len(non_none) == 1:
            return _type_to_schema(non_none[0])

    # Primitive fallback
    return {str: {"type": "string"}, int: {"type": "integer"},
            float: {"type": "number"}, bool: {"type": "boolean"}}.get(annotation, {"type": "string"})
Enter fullscreen mode Exit fullscreen mode

The reason this matters in practice: tool parameters with fixed option sets are common. Status fields, sort directions, format specifiers, action types. If you write these as plain str in the schema, the model will guess values and sometimes guess wrong. If you include enum, the model sticks to valid values. The annotation already has the information. The schema should reflect it.

When This Is Useful

You have a growing set of tool functions and want to generate schemas without a separate maintenance step. When a function signature changes, the schema updates automatically on the next call to schema_for.

You are working in a codebase where functions and schemas live separately and tend to drift apart. Generating from the function eliminates that gap.

You use Literal annotations for parameters with a fixed set of valid values. The enum translation is automatic and accurate.

You add Google-style docstrings to your functions anyway, and want those descriptions to appear in the schema without copy-pasting.

When NOT to Use This

Your function uses complex types. If parameters are Pydantic models, dataclasses, or TypedDicts, the library will not automatically expand them into nested schema objects. The schema for a complex-typed parameter will be incomplete.

Your team does not use type annotations consistently. The library relies on annotations being present and correct. If half the functions have no annotations, schema generation produces incomplete output for those functions.

You need a schema that diverges from the function signature. Sometimes a tool accepts a parameter internally but should not expose it to the model. There is no mechanism to exclude parameters during generation. You would need to wrap or manually adjust.

Install

pip install tool-schema-from-fn
Enter fullscreen mode Exit fullscreen mode

Zero dependencies. Works with Python 3.9 and above.

from tool_schema_from_fn import schema_for
from typing import Literal, Optional

def send_message(
    channel: str,
    text: str,
    priority: Literal["normal", "urgent"] = "normal",
    thread_ts: Optional[str] = None
) -> dict:
    """Send a message to a channel.

    Args:
        channel: The channel identifier.
        text: Message body.
        priority: Message priority level.
        thread_ts: Optional thread timestamp to reply into.
    """
    ...

schema = schema_for(send_message, provider="anthropic")
Enter fullscreen mode Exit fullscreen mode

The repo is at MukundaKatta/tool-schema-from-fn. 25 tests, all passing. No runtime dependencies.

Siblings

These four libraries connect to the same boundary. Each handles a different part of the tool-call lifecycle.

Lib Boundary Repo
agentvet Validate tool args against the schema at runtime MukundaKatta/agentvet
tool-arg-defaults Fill missing kwargs before calling the function MukundaKatta/tool-arg-defaults
tool-arg-coerce-py Coerce args to the types the function expects MukundaKatta/tool-arg-coerce-py
agent-fn-registry Store function, schema, side effects, and defaults together MukundaKatta/agent-fn-registry

The intended pattern is: generate the schema with tool-schema-from-fn, validate incoming args with agentvet, fill missing optional args with tool-arg-defaults, coerce types with tool-arg-coerce-py, and register the whole bundle in agent-fn-registry. Each library is independent. You can use any one without the others.

What Is Next

A few things would improve this library. Pydantic model expansion is the most requested missing feature. A mode that walks a model's model_fields and produces a nested schema object would make this useful in more codebases.

A per-parameter override mechanism would also help. Something like a decorator or a separate metadata dict that lets you exclude a parameter from the generated schema, override its description, or force a different type. That covers the cases where the function signature and the desired schema do not align exactly.

The Literal support currently handles string literals. Integer literals, such as Literal[1, 2, 3], are not tested thoroughly. That is a gap worth closing.

For now, the core use case is covered: annotated functions with string parameters, optional fields, list and dict types, and Google docstrings. If your tools fit that shape, the library removes a maintenance surface that tends to produce subtle bugs at the worst time.

The bug that started this article took two iterations of ToolArgError to find. It was a typo in a string I had written by hand. The fix is to stop writing that string by hand.

Top comments (0)