Problem: GPT-5 Doesn't Know Your Internal Data
You need the model to fetch live order statuses, query your user database, or call your internal pricing API — but GPT-5 only knows what's in its training data. Out of the box it can't touch your systems.
Function calling bridges that gap. You define the tools; the model decides when and how to call them.
You'll learn:
- How to define function schemas GPT-5 can reason about
- How to wire function calls to real REST API requests
- How to handle auth, retries, and multi-turn conversations in production
Time: 25 min | Level: Intermediate
Why This Happens
GPT-5 (and every other LLM) is stateless. It generates text — including structured JSON — but it doesn't make HTTP requests itself. Function calling is a protocol where:
- You describe available functions as JSON schemas in the API request
- The model returns a structured
tool_callinstead of prose when it decides a function is needed - Your code executes the actual function and returns the result
- The model uses that result to generate a final response
The model never "calls" anything. You do. The model just tells you what to call and with what arguments.
Common misconceptions:
- "The model calls my API directly" — it doesn't, your code does
- "I need to expose my API publicly" — you don't, it runs server-side
- "This only works with simple GET requests" — it works with any HTTP method
Solution
Step 1: Define Your Function Schema
The schema is the contract between your code and the model. Be precise — vague descriptions produce wrong argument choices.
// tools/orderSchema.ts
export const orderTools = [
{
type: "function" as const,
function: {
name: "get_order_status",
// GPT-5 reads this description to decide when to call this function
description:
"Retrieve the current status and shipping details for a customer order. Use this when the user asks about an order, delivery, or shipment.",
parameters: {
type: "object",
properties: {
order_id: {
type: "string",
// Enum narrows the model's output and reduces hallucinated IDs
description: "The order ID in format ORD-XXXXXXXX (e.g. ORD-00293847)",
},
include_history: {
type: "boolean",
description: "If true, returns full status history. Defaults to false.",
},
},
required: ["order_id"],
},
},
},
{
type: "function" as const,
function: {
name: "search_products",
description:
"Search the internal product catalog by keyword, category, or SKU. Use when user asks about product availability, pricing, or specifications.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "Search keyword or product name",
},
category: {
type: "string",
enum: ["electronics", "apparel", "home", "sports"],
description: "Optional category filter",
},
limit: {
type: "integer",
description: "Max results to return (1–20). Defaults to 5.",
},
},
required: ["query"],
},
},
},
];
Expected: The tools array is ready to pass directly to the OpenAI API.
If it fails:
- "Unknown parameter type": Stick to
string,number,integer,boolean,array,object— OpenAI validates against JSON Schema Draft 7 - Model ignores the function: Make the
descriptionmore specific about when to use it, not just what it does
Step 2: Execute the First Turn
Send the user message along with the tool definitions. The model will either respond with text or request a function call.
// lib/chat.ts
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function runChatTurn(
messages: OpenAI.Chat.ChatCompletionMessageParam[],
tools: OpenAI.Chat.ChatCompletionTool[]
): Promise<OpenAI.Chat.ChatCompletion> {
return client.chat.completions.create({
model: "gpt-5",
messages,
tools,
// "auto" lets the model decide; "required" forces a tool call every turn
tool_choice: "auto",
// Parallel calls let GPT-5 batch multiple function calls in one response
parallel_tool_calls: true,
});
}
// index.ts
import { orderTools } from "./tools/orderSchema";
import { runChatTurn } from "./lib/chat";
const messages = [
{
role: "system" as const,
content:
"You are a customer support assistant. Use the provided tools to answer questions about orders and products. Never make up order statuses.",
},
{
role: "user" as const,
content: "What's the status of my order ORD-00293847? Does it include tracking?",
},
];
const response = await runChatTurn(messages, orderTools);
const message = response.choices[0].message;
console.log("Finish reason:", response.choices[0].finish_reason);
// "tool_calls" means the model wants to call a function
// "stop" means it responded directly with text
Expected: finish_reason is "tool_calls" and message.tool_calls contains an array with one or more calls.
Step 3: Execute the Function and Return the Result
This is where your actual API call happens. The model's output tells you the function name and arguments — your code does the rest.
// lib/apiClient.ts
interface OrderStatus {
order_id: string;
status: "processing" | "shipped" | "delivered" | "cancelled";
tracking_number?: string;
estimated_delivery?: string;
history?: { timestamp: string; status: string }[];
}
export async function getOrderStatus(
orderId: string,
includeHistory = false
): Promise<OrderStatus> {
const url = new URL(`${process.env.INTERNAL_API_BASE}/orders/${orderId}`);
if (includeHistory) url.searchParams.set("history", "true");
const res = await fetch(url.toString(), {
headers: {
// Your internal API auth — never expose this to the client
Authorization: `Bearer ${process.env.INTERNAL_API_SECRET}`,
"Content-Type": "application/json",
},
// Abort if the internal API is slow — don't let it hang the LLM response
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
// Return a meaningful error string — the model will relay it to the user
throw new Error(`Order API returned ${res.status}: ${await res.text()}`);
}
return res.json();
}
// lib/toolDispatcher.ts
import { getOrderStatus } from "./apiClient";
type ToolResult = string; // OpenAI tool results must be strings (JSON-encoded)
export async function dispatchToolCall(
name: string,
args: Record<string, unknown>
): Promise<ToolResult> {
switch (name) {
case "get_order_status": {
const result = await getOrderStatus(
args.order_id as string,
(args.include_history as boolean) ?? false
);
// Stringify — the model reads this as context for its next response
return JSON.stringify(result);
}
case "search_products": {
// Wire your product search endpoint here
const result = await searchProducts(
args.query as string,
args.category as string | undefined,
(args.limit as number) ?? 5
);
return JSON.stringify(result);
}
default:
// Fail loudly — unknown tool names indicate a schema/code mismatch
throw new Error(`Unknown tool: ${name}`);
}
}
Step 4: Send Results Back and Get the Final Response
The conversation requires a second API call with the tool results appended. This is the multi-turn loop.
// Complete the agentic loop
import { dispatchToolCall } from "./lib/toolDispatcher";
import type OpenAI from "openai";
async function agentLoop(
initialMessages: OpenAI.Chat.ChatCompletionMessageParam[],
tools: OpenAI.Chat.ChatCompletionTool[]
) {
let messages = [...initialMessages];
while (true) {
const response = await runChatTurn(messages, tools);
const choice = response.choices[0];
// Model responded with text — we're done
if (choice.finish_reason === "stop") {
return choice.message.content;
}
// Model wants tool calls — execute them all (possibly in parallel)
if (choice.finish_reason === "tool_calls" && choice.message.tool_calls) {
// Append the assistant's tool_call message to history
messages.push(choice.message);
// Execute all requested tool calls concurrently
const toolResults = await Promise.all(
choice.message.tool_calls.map(async (toolCall) => {
let result: string;
try {
result = await dispatchToolCall(
toolCall.function.name,
JSON.parse(toolCall.function.arguments)
);
} catch (err) {
// Surface errors to the model so it can tell the user gracefully
result = JSON.stringify({ error: (err as Error).message });
}
return {
role: "tool" as const,
tool_call_id: toolCall.id, // Must match the ID from the request
content: result,
};
})
);
// Append all results before the next turn
messages.push(...toolResults);
// Loop — model will now generate a response using the tool results
continue;
}
// Unexpected finish reason
throw new Error(`Unexpected finish_reason: ${choice.finish_reason}`);
}
}
// Run it
const finalResponse = await agentLoop(messages, orderTools);
console.log(finalResponse);
// → "Your order ORD-00293847 has shipped. Tracking number: 1Z999AA10123456784.
// Estimated delivery: February 24. Would you like the full status history?"
If it fails:
- "tool_call_id does not match": The
tool_call_idin yourtoolrole message must exactly match the ID frommessage.tool_calls[n].id - Model ignores tool results: Make sure the assistant's
message(withtool_calls) is appended to history before the tool result messages - Infinite loop: Add a
maxTurnscounter — a well-scoped tool schema rarely needs more than 3 iterations
Verification
# Smoke test with a real user query
npx ts-node index.ts
You should see:
Finish reason: tool_calls
Dispatching: get_order_status { order_id: 'ORD-00293847', include_history: false }
Internal API responded in 120ms
Final response: Your order ORD-00293847 shipped on February 19 via UPS...
Run a second test with an invalid order ID to confirm error handling:
// Should gracefully surface the API error to the user
const badMessages = [
...systemMessage,
{ role: "user" as const, content: "Check order ORD-99999999" },
];
const result = await agentLoop(badMessages, orderTools);
// → "I wasn't able to find order ORD-99999999. Please double-check the ID
// and try again, or contact support if the issue persists."
What You Learned
- The model generates structured
tool_callJSON — your code executes the actual HTTP request - Tool descriptions drive when the model calls a function; parameter schemas drive how
- The multi-turn loop appends tool results and calls the API again — budget for two API round-trips per user message
- Error strings returned as tool results let the model handle failures gracefully without crashing your app
Limitations to know:
- Each tool result counts toward the context window — with many tools and large responses, costs add up fast
parallel_tool_callsis useful but requires all results before the next turn; sequential dependencies need multiple loop iterations- Never pass sensitive data (PII, auth tokens) back in tool results — the model logs these as context
When NOT to use function calling:
- Simple Q&A where retrieval-augmented generation (RAG) is cheaper
- High-frequency, latency-sensitive paths — two API round-trips add 300–800ms
- When the action is always the same regardless of user input — just call your API directly
Tested on GPT-5 (gpt-5), OpenAI Node SDK 5.x, TypeScript 5.8, Node.js 22 LTS