Frontier Engineering
← Tool Calling & Function Execution

Tool Schema as Interface Design

Also known as: function schema design, tool descriptions as prompt

Treat every tool definition as prompt engineering: the schema is what the model reads to decide whether, when, and how to call it.

8 min read

The problem

Teams port their internal API surface directly into tool definitions and then blame the model for choosing badly. A tool named `execute_query` described as "runs a query" gives the model nothing to reason with. Overlapping tools with vague boundaries produce arbitrary selection; parameters named after database columns produce malformed arguments; tools that do five things produce calls that do the wrong one. The result looks like a model capability problem and is actually an interface design problem.

The pattern

Design tools for the model as you would design a public API for a new developer with no documentation beyond the signature. One responsibility per tool, names that describe the user-visible outcome, descriptions that state when to use it and when not to, parameters with explicit types, enums for closed sets, and examples in the description for anything ambiguous. Return errors as structured, actionable observations rather than exceptions.

Use when

  • Any time you expose tools to a model — this is the baseline, not an optimization
  • Tool selection accuracy is below what the task needs and the model is already capable
  • Several tools have overlapping purposes and the model picks inconsistently between them
  • Arguments come back malformed, out of range, or with invented enum values

Avoid when

  • Never — but resist over-describing: a 400-word description for a trivial tool wastes context on every turn
  • Do not add tools speculatively; an unused tool still costs tokens and still competes for selection

Tradeoffs

  • GainLarge selection-accuracy gains for a few hours of writing, no model change required

    CostDescriptions consume context on every single request, so verbosity has a running cost

  • GainNarrow single-purpose tools are easier to test, permission, and audit independently

    CostMore tools in the list, which itself degrades selection past roughly thirty

  • GainStructured errors let the model self-correct instead of failing the whole run

    CostEvery tool needs an error contract designed, not just a happy path


The tool schema is not configuration. It is part of the prompt — the model reads names, descriptions, and parameter docs on every turn and makes decisions from them. Time spent here consistently outperforms time spent tuning the system prompt, and often outperforms upgrading the model.

One tool, one outcome

A tool should do one thing that a user would recognize as a single action. `manage_calendar(action, ...)` with an action enum forces the model to make two decisions at once and makes the parameter set conditional on the action, which is exactly where argument errors come from. Split it: `create_event`, `cancel_event`, `find_free_slots`. Each gets a clean, non-conditional parameter set, and each can be permissioned separately.

# Weak: conditional parameters, vague outcome, no guidance on when to call it.
BAD = {
    "name": "manage_calendar",
    "description": "Manage the user's calendar.",
    "parameters": {
        "type": "object",
        "properties": {
            "action": {"type": "string"},
            "data": {"type": "object"},
        },
    },
}

# Strong: one outcome, explicit formats, stated boundary against a sibling tool.
GOOD = {
    "name": "find_free_slots",
    "description": (
        "Find times when every listed attendee is free. Use this BEFORE "
        "create_event whenever the user has not named an exact time. "
        "Returns up to 10 candidate slots in the organizer's timezone. "
        "Does not book anything."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "attendees": {
                "type": "array",
                "items": {"type": "string", "format": "email"},
                "description": "Email addresses. Include the organizer.",
            },
            "duration_minutes": {"type": "integer", "minimum": 15, "maximum": 480},
            "earliest": {
                "type": "string",
                "format": "date-time",
                "description": "ISO 8601 with offset, e.g. 2026-03-04T09:00:00-05:00.",
            },
            "latest": {"type": "string", "format": "date-time"},
        },
        "required": ["attendees", "duration_minutes", "earliest", "latest"],
    },
}

Write the description for selection, not documentation

The description's job is to help the model decide. A useful description answers three questions in order: what outcome does this produce, when should it be used, and when should something else be used instead. That last clause is the one teams skip and the one that resolves most selection ambiguity — an explicit boundary between two similar tools is worth more than two paragraphs describing each of them.

  • Name tools after the outcome (`find_free_slots`), not the implementation (`query_availability_index`)
  • State the negative boundary explicitly: "Use search_orders instead when the user has an order number"
  • Use enums for closed sets — a free-form string invites invented values
  • Document formats by example inside the parameter description; "ISO 8601" alone is under-specified
  • Say whether the tool mutates state, and whether the mutation is reversible
tip

Read your tool list the way the model sees it: dump the rendered schemas and read them cold, with no knowledge of your system. Every ambiguity you notice in that reading is one the model is resolving by guessing.

Return observations, not exceptions

A tool that raises into the agent loop ends the run. A tool that returns a structured description of what went wrong lets the model correct itself on the next iteration — and the difference between those two is usually a single try/except. Say what failed, why, and what would work instead.

def find_free_slots(**kwargs) -> str:
    try:
        args = FindFreeSlots(**kwargs)   # pydantic validates types and ranges
    except ValidationError as e:
        # The model reads this and retries with corrected arguments.
        return json.dumps({
            "error": "invalid_arguments",
            "details": [f"{'.'.join(map(str, x['loc']))}: {x['msg']}" for x in e.errors()],
            "hint": "earliest and latest must be ISO 8601 with a UTC offset.",
        })

    slots = calendar.free_busy(args)
    if not slots:
        return json.dumps({
            "error": "no_availability",
            "searched": {"from": args.earliest, "to": args.latest},
            "hint": "Widen the window or reduce duration_minutes, then call again.",
        })

    return json.dumps({"slots": [s.isoformat() for s in slots[:10]]})
warning

Never let a tool return a raw stack trace or a raw provider error blob. It burns hundreds of tokens, leaks internals into the context, and tells the model nothing it can act on.