Agent Surface
API Surface

Universal Tool Design Patterns

Cross-framework principles for defining tools that agents select correctly and use reliably

Summary

Tool definitions consist of three universal fields across all frameworks: name, description, and schema. The description acts as a prompt that guides agent selection and behavior. This section covers the three-field minimum, how descriptions function as prompts (not documentation), and common pitfalls in tool design that lead to agents selecting wrong tools or constructing invalid inputs.

  • name: machine identifier used as function name in generated code
  • description: prompt instructing agents when and why to call the tool
  • schema: JSON Schema defining input parameters and constraints
  • Include positive and negative trigger conditions in descriptions
  • Document prerequisites and output shape to aid agent reasoning

Every major agent framework — MCP, Vercel AI SDK, Mastra, LangChain, OpenAI, CrewAI, Semantic Kernel, Amazon Bedrock — uses a different syntax for tool definitions. The underlying design principles are identical across all of them. This page covers those universal principles, then shows how each framework implements them.

The Three-Field Minimum

Every tool in every framework requires exactly three things:

FieldPurpose
nameMachine identifier; the function name in generated code
descriptionNatural language instructions telling the agent when to call this tool
schemaJSON Schema or equivalent defining the input parameters

Some frameworks add optional fields (output schema, annotations, execution handler), but these three are universal. Get them right before adding anything else.

Descriptions Are Prompts, Not Documentation

This is the most commonly misunderstood aspect of tool design. The description field is not human documentation — it is a prompt that the LLM reads when deciding which tool to invoke.

Effective descriptions include:

  • Positive trigger conditions: "Call this when the user wants to..."
  • Negative trigger conditions: "Do not use this if... use X instead"
  • Input prerequisites: "Requires a valid order ID from a previous list_orders call"
  • Output summary: "Returns the complete order including line items and shipping status"
// Bad — describes implementation, not agent behavior
{
  name: "get_order",
  description: "Fetches an order from the database by order ID.",
  schema: { ... }
}

// Good — tells the agent when and why to call this tool
{
  name: "get_order",
  description: `Retrieves full details for a single order including line items,
    payment status, and shipping information. Use this when the user asks about
    a specific order and you have the order ID. For searching orders by customer
    or date range, use search_orders instead. Returns 404 if the order does not
    exist or belongs to a different account.`,
  schema: { ... }
}

Fewer Tools, Better Selection

Agent frameworks route tool calls by semantic similarity between the user's request and tool descriptions. Adding more tools increases the chance of routing to the wrong one.

  • OpenAI recommendation: fewer than 10 tools per agent session
  • Hard ceiling: 20 tools before selection accuracy degrades significantly
  • Best practice: expose a curated subset of your full API surface per use case

If your API has 100 endpoints, do not generate 100 tools. Group, filter, and compose. The agent that does one job well is more reliable than the one that can do everything.

When you need to expose a large API surface, use dynamic tool selection rather than loading all tools at once. See the Dynamic Tool Selection section below.

Separation of Interface from Execution

Tool definitions should separate what the agent sees from what the server runs. The agent sees the name, description, and schema. The execution layer handles authentication, database access, HTTP calls, and side effects.

This separation matters for two reasons:

  1. Context parameters must not be tool parameters. The agent should never be asked to supply authentication tokens, user IDs derived from the session, or internal system identifiers. These flow through the execution layer, not through the tool call.
  2. Tool interfaces are stable; implementations change. An agent that hard-codes specific parameter patterns to a tool definition will break when you change the underlying implementation.
// Bad — agent is responsible for auth context it should never handle
{
  name: "create_document",
  schema: {
    properties: {
      auth_token: { type: "string" },     // Wrong — execution layer concern
      user_id: { type: "string" },        // Wrong — comes from session
      organization_id: { type: "string" }, // Wrong — comes from session
      title: { type: "string" },
      content: { type: "string" }
    }
  }
}

// Good — only domain inputs; auth/session context injected at runtime
{
  name: "create_document",
  schema: {
    properties: {
      title: { type: "string" },
      content: { type: "string" },
      folder_id: { type: "string", description: "Optional folder to place the document in" }
    },
    required: ["title"]
  }
}

Tool Composition Patterns

Read-Then-Write

When a write operation requires information that is not in the agent's current context, expose a paired read tool. The agent calls the read tool first to gather the required IDs or state, then calls the write tool.

list_projects → create_task (requires project_id)
get_user → update_user (requires current values to avoid overwriting)
list_team_members → assign_task (requires assignee_id)

List-Before-Get

For detail operations that require an ID, ensure the corresponding list operation exists and returns the ID field. Agents that cannot list cannot get.

Dry-Run-Before-Execute

For destructive or irreversible operations, consider exposing a preview tool:

preview_invoice → send_invoice
validate_migration → run_migration
estimate_charges → execute_charge

The dry-run tool describes what would happen without doing it, giving the agent (and the human overseeing it) a chance to review before committing.

Idempotent by Design

Tools that agents may retry should be idempotent. If calling create_invoice twice with the same inputs creates two invoices, a retrying agent will cause billing duplicates. Design write tools to accept an idempotency key, or use upsert semantics where appropriate.

Dynamic Tool Selection

When your full tool surface exceeds 20 tools, do not load all tools into the context at once. Use dynamic selection to load only the relevant tools for the current task.

Anthropic's recommended pattern searches a tool registry using BM25 ranking before each tool selection step:

// Conceptual pattern — search tool registry before injecting tools
async function selectTools(userQuery: string, allTools: Tool[]): Promise<Tool[]> {
  const ranked = bm25Search(userQuery, allTools, { topK: 5 })
  return ranked
}

Vercel AI SDK activeTools

Pass a subset of tools per conversation turn:

const result = await generateText({
  model,
  tools: allTools,
  activeTools: ["get_order", "list_orders", "cancel_order"], // only these are active
  prompt: userMessage
})

OpenAI defer_loading

Mark tools as deferred so the model can request loading them on demand rather than having them all in context from the start.

Parallel vs Sequential Tool Use

Agents can call multiple tools in a single response (parallel tool use) or call tools one at a time (sequential). Design tool interfaces to support both.

Parallel-safe tools are independent read operations with no shared state:

get_user + get_order + get_invoice  →  can run simultaneously

Sequential-required tools have data dependencies:

create_order → add_line_items → calculate_totals → submit_order

Make dependencies explicit in tool descriptions rather than relying on agents to infer them from schema structure. "Requires an order_id from a prior create_order call" is clear. A schema that just has order_id: string is not.

Framework Implementations

MCP

MCP tool definitions include name, description, inputSchema, an optional outputSchema, and annotations for behavioral hints.

{
  "name": "get_order",
  "description": "Retrieves full details for a single order. Use when you have an order ID and need complete order information including line items and shipping status. For searching orders, use search_orders.",
  "inputSchema": {
    "type": "object",
    "required": ["order_id"],
    "properties": {
      "order_id": {
        "type": "string",
        "description": "The order identifier returned by list_orders or create_order"
      }
    }
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "id": { "type": "string" },
      "status": { "type": "string", "enum": ["pending", "processing", "shipped", "delivered", "cancelled"] },
      "total": { "type": "number" },
      "line_items": {
        "type": "array",
        "items": { "$ref": "#/components/schemas/LineItem" }
      }
    }
  },
  "annotations": {
    "readOnlyHint": true,
    "idempotentHint": true
  }
}

MCP annotations signal behavioral properties to the client:

AnnotationMeaning
readOnlyHint: trueTool does not modify server state
idempotentHint: trueCalling multiple times with same inputs has same effect
destructiveHint: trueTool may delete or irreversibly modify data
openWorldHint: trueTool interacts with external systems beyond the server

Vercel AI SDK

The AI SDK tool() function takes a description, a Zod input schema, and an execute function. The toModelOutput field shapes what the model sees after execution.

import { tool } from "ai"
import { z } from "zod"

const getOrder = tool({
  description: `Retrieves full details for a single order including line items,
    payment status, and shipping information. Use when you have a specific order ID.
    For searching orders by customer or date, use searchOrders instead.`,
  parameters: z.object({
    orderId: z.string().describe("The order ID returned by listOrders or createOrder")
  }),
  execute: async ({ orderId }) => {
    const order = await db.orders.findUnique({ where: { id: orderId } })
    if (!order) throw new Error(`Order ${orderId} not found`)
    return order
  }
})

For human-in-the-loop patterns, set needsApproval to pause execution before the tool runs:

const deleteOrder = tool({
  description: "Permanently deletes an order. This cannot be undone.",
  parameters: z.object({
    orderId: z.string()
  }),
  needsApproval: async ({ orderId }) => {
    // Return true to require human approval before execution
    const order = await db.orders.findUnique({ where: { id: orderId } })
    return order?.status === "completed" // Require approval for completed orders
  },
  execute: async ({ orderId }) => {
    await db.orders.delete({ where: { id: orderId } })
    return { deleted: true, orderId }
  }
})

Mastra

Mastra tools use createTool with Zod schemas and a typed execute context. The toModelOutput field lets you transform tool results before they re-enter the model context.

import { createTool } from "@mastra/core/tools"
import { z } from "zod"

export const getOrderTool = createTool({
  id: "get-order",
  description: `Retrieves full details for a single order. Use when you have a
    specific order ID and need complete information including line items and status.
    For searching or listing orders, use the search-orders tool instead.`,
  inputSchema: z.object({
    orderId: z.string().describe("Order ID from a prior list-orders or create-order call")
  }),
  outputSchema: z.object({
    id: z.string(),
    status: z.enum(["pending", "processing", "shipped", "delivered", "cancelled"]),
    total: z.number(),
    lineItems: z.array(z.object({
      productId: z.string(),
      quantity: z.number(),
      unitPrice: z.number()
    }))
  }),
  execute: async ({ context }) => {
    const order = await fetchOrder(context.orderId)
    return order
  }
})

Mastra also supports MCP annotations via mcp.annotations:

export const deleteOrderTool = createTool({
  id: "delete-order",
  description: "Permanently deletes an order. Cannot be undone.",
  inputSchema: z.object({ orderId: z.string() }),
  mcp: {
    annotations: {
      destructiveHint: true,
      idempotentHint: false
    }
  },
  execute: async ({ context }) => { ... }
})

LangChain

LangChain uses the @tool decorator with Pydantic models for schema definition. Context parameters (auth, session state) flow through ToolRuntime, not through tool parameters.

from langchain_core.tools import tool
from pydantic import BaseModel, Field

class GetOrderInput(BaseModel):
    order_id: str = Field(
        description="The order ID returned by list_orders or create_order"
    )

@tool("get_order", args_schema=GetOrderInput)
def get_order(order_id: str) -> dict:
    """Retrieves full details for a single order including line items and status.
    
    Use when you have a specific order ID. For searching orders by customer
    or date range, use search_orders instead. Returns 404 error if the order
    does not exist or belongs to a different account.
    """
    order = db.orders.get(order_id)
    if not order:
        raise ToolException(f"Order {order_id} not found")
    return order.to_dict()

For injecting runtime context (database session, authenticated user):

from langchain_core.tools import InjectedToolArg
from typing import Annotated

@tool
def create_order(
    product_ids: list[str],
    # Injected at runtime — never passed by the agent
    db_session: Annotated[Session, InjectedToolArg],
    current_user: Annotated[User, InjectedToolArg]
) -> dict:
    """Creates a new order for the current user."""
    ...

OpenAI

OpenAI function tools use function_tool with strict mode and additionalProperties: false to prevent agents from passing unexpected parameters.

from openai import OpenAI
from openai.lib._tools import function_tool

@function_tool
def get_order(order_id: str) -> dict:
    """Retrieves full details for a single order.
    
    Use when you have a specific order ID and need complete order information
    including line items, payment status, and shipping details. For searching
    orders by customer or date, use search_orders instead.
    
    Args:
        order_id: The order identifier returned by list_orders or create_order
    """
    ...

In the JSON representation, strict mode requires additionalProperties: false at every object level:

{
  "type": "function",
  "function": {
    "name": "get_order",
    "description": "Retrieves full details for a single order...",
    "strict": true,
    "parameters": {
      "type": "object",
      "required": ["order_id"],
      "additionalProperties": false,
      "properties": {
        "order_id": {
          "type": "string",
          "description": "The order identifier"
        }
      }
    }
  }
}

CrewAI

CrewAI assigns tools to agents through the agent.tools list rather than a central tool registry. Each agent's role, goal, and backstory define its operational context.

from crewai import Agent, Task, Crew
from crewai_tools import BaseTool

class GetOrderTool(BaseTool):
    name: str = "get_order"
    description: str = """Retrieves full details for a single order including
        line items and shipping status. Use when you have an order ID and need
        complete order information."""

    def _run(self, order_id: str) -> str:
        order = fetch_order(order_id)
        return order.to_json()

order_agent = Agent(
    role="Order Manager",
    goal="Help customers track and manage their orders accurately",
    backstory="An expert in order fulfillment with access to the order management system",
    tools=[GetOrderTool()],
    verbose=True
)

Semantic Kernel

Semantic Kernel uses KernelFunction annotations on class methods. Tools are grouped into plugins and injected through dependency injection.

from semantic_kernel.functions import kernel_function
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior

class OrderPlugin:
    def __init__(self, order_service: OrderService):
        self._order_service = order_service

    @kernel_function(
        name="get_order",
        description="""Retrieves full details for a single order. Use when you have
            a specific order ID and need complete information including line items
            and shipping status. For searching orders, use search_orders instead."""
    )
    async def get_order(
        self,
        order_id: Annotated[str, "The order ID from list_orders or create_order"]
    ) -> str:
        order = await self._order_service.get(order_id)
        return json.dumps(order.to_dict())

Amazon Bedrock

Bedrock supports tools via either an OpenAPI schema (for Bedrock Agents action groups) or a direct function detail schema (for inline tool use).

OpenAPI schema approach — the entire API is described in OpenAPI 3.0 and uploaded as an action group. See the OpenAPI for Agents page for spec design guidance.

Function detail schema — for direct tool use in the Converse API:

tool_config = {
    "tools": [
        {
            "toolSpec": {
                "name": "get_order",
                "description": """Retrieves full details for a single order including
                    line items, payment status, and shipping information. Use when you
                    have a specific order ID. For searching orders, use search_orders.""",
                "inputSchema": {
                    "json": {
                        "type": "object",
                        "required": ["order_id"],
                        "properties": {
                            "order_id": {
                                "type": "string",
                                "description": "The order identifier"
                            }
                        }
                    }
                }
            }
        }
    ]
}

Bedrock also supports x-requireConfirmation for operations that need human approval before execution. See OpenAPI Extensions for details.

Checklist

Before shipping tool definitions to production:

  • Every tool has a description that includes positive and negative trigger conditions
  • No tool has more than 10 parameters
  • Context parameters (auth, session IDs) are injected at runtime, not passed through tool arguments
  • Tool names are unique, unambiguous, and use verb_noun format
  • Destructive or irreversible tools are annotated appropriately
  • Paired read/write tools exist where write operations require prior state
  • Idempotency behavior is documented in the description
  • The number of tools exposed per agent session is under 20

On this page