Beyond Text Output
Function calling lets LLMs do something they can't do alone: take structured action. Instead of completing your prompt with text, the model returns a structured call to a function you define โ with typed arguments it extracted from the conversation. You execute the function, pass the result back, and the model continues. This is how agents work.
- You're regex-parsing LLM outputs to extract structured data, and it breaks on edge cases
- You have an agent loop that sometimes calls the wrong tool because the descriptions are vague
- You need the model to trigger external actions (database query, API call) based on conversation context
- Your LLM sometimes calls a tool with invalid arguments and your code throws at runtime
Well-defined tool schemas eliminate the parsing layer and make LLM-to-function calls as reliable as typed function signatures.
Tool Schema Design
The model selects tools and generates arguments based entirely on your schema descriptions. Bad descriptions mean wrong tool selection. Treat tool descriptions like API documentation โ write them for someone who can't see your code.
sequenceDiagram
participant User
participant LLM
participant App
participant DB
User->>LLM: "Show me orders from last week over $500"
LLM->>App: tool_call: searchOrders({minAmount:500, dateFrom:"2025-01-13"})
App->>DB: SELECT * FROM orders WHERE ...
DB->>App: [{id: 123, amount: 750, ...}]
App->>LLM: tool_result: [{id:123, ...}, {id:456, ...}]
LLM->>User: "Found 2 orders: order #123 for $750..."
import OpenAI from 'openai';
const openai = new OpenAI();
// Define tools with precise descriptions โ the model reads these
const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
{
type: 'function',
function: {
name: 'searchOrders',
description: `Search customer orders by date range and amount.
Use when the user asks about orders, purchases, or transactions.
Returns an array of order objects with id, amount, status, and date.`,
parameters: {
type: 'object',
properties: {
minAmount: {
type: 'number',
description: 'Minimum order amount in USD. Omit if no minimum.',
},
maxAmount: {
type: 'number',
description: 'Maximum order amount in USD. Omit if no maximum.',
},
dateFrom: {
type: 'string',
description: 'Start date in ISO 8601 format (YYYY-MM-DD). Omit for all dates.',
},
dateTo: {
type: 'string',
description: 'End date in ISO 8601 format (YYYY-MM-DD). Omit for today.',
},
status: {
type: 'string',
enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled'],
description: 'Filter by order status. Omit to return all statuses.',
},
},
required: [], // All params optional โ model decides what to include
additionalProperties: false,
},
},
},
];The Conversation Loop
type Message = OpenAI.Chat.Completions.ChatCompletionMessageParam;
// Tool implementations
const toolHandlers: Record<string, (args: unknown) => Promise<unknown>> = {
searchOrders: async (args) => {
// CRITICAL: validate args before executing
const parsed = SearchOrdersSchema.parse(args); // Zod validation
return db.orders.findMany({
where: {
amount: { gte: parsed.minAmount, lte: parsed.maxAmount },
createdAt: { gte: parsed.dateFrom, lte: parsed.dateTo },
status: parsed.status,
},
});
},
};
async function runAgentLoop(userMessage: string): Promise<string> {
const messages: Message[] = [
{ role: 'system', content: 'You are a helpful order management assistant.' },
{ role: 'user', content: userMessage },
];
// Loop until the model stops calling tools
while (true) {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
tools,
tool_choice: 'auto', // model decides when to call tools
});
const message = response.choices[0].message;
messages.push(message);
// No tool calls โ final text response
if (!message.tool_calls) {
return message.content ?? '';
}
// Execute each tool call
for (const toolCall of message.tool_calls) {
const args = JSON.parse(toolCall.function.arguments);
const result = await toolHandlers[toolCall.function.name]?.(args) ?? 'Tool not found';
// Append tool result to conversation
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(result),
});
}
// Loop continues โ model sees tool results and decides next step
}
}Parallel Tool Calls
// When the model calls multiple tools in one turn, execute them in parallel
// This is a performance optimisation โ the model already decided to call all of them
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
tools,
});
const toolCalls = response.choices[0].message.tool_calls ?? [];
// Execute all tool calls in this turn concurrently
const toolResults = await Promise.all(
toolCalls.map(async (toolCall) => {
const args = JSON.parse(toolCall.function.arguments);
const result = await toolHandlers[toolCall.function.name]?.(args);
return {
role: 'tool' as const,
tool_call_id: toolCall.id,
content: JSON.stringify(result),
};
})
);
messages.push(response.choices[0].message, ...toolResults);Controlling Tool Use
// tool_choice controls whether and which tools the model uses
// auto โ model decides (default)
tool_choice: 'auto'
// none โ never use tools (text-only response)
tool_choice: 'none'
// required โ must call at least one tool
tool_choice: 'required'
// specific function โ must call this exact function
tool_choice: { type: 'function', function: { name: 'searchOrders' } }
// Use 'required' for structured data extraction tasks:
// "Extract the key details from this support ticket"
// + required tool: extractTicketDetails
// Guarantees structured output, never returns unstructured textPitfalls
Trusting model-generated arguments directly
Never execute a tool call with unvalidated model output. The model can hallucinate argument values, generate SQL fragments, produce out-of-range numbers, or construct paths that traverse directories. Always validate against a schema (Zod, Joi, AJV) before passing to your handler. Treat LLM-generated arguments like untrusted user input.
Overloading a single tool
A callDatabase(sql: string) tool that accepts raw SQL is a security and reliability disaster. Use narrow, typed tools with explicit parameters. The model should be selecting typed parameters โ not constructing strings that your code interprets.
Missing tool call IDs in history
Every tool_calls entry in the assistant message must have a corresponding tool message with the matchingtool_call_id. Missing a tool result causes an API error on the next turn. If a tool fails, still append a tool message with an error description so the model can gracefully handle it.

