Mastering Function Calling: Connecting GPT-5 to Your Internal REST APIs

Connect GPT-5 to your internal REST APIs using function calling. Handle auth, errors, and streaming responses in production-ready TypeScript.

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:

  1. You describe available functions as JSON schemas in the API request
  2. The model returns a structured tool_call instead of prose when it decides a function is needed
  3. Your code executes the actual function and returns the result
  4. 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 description more 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_id in your tool role message must exactly match the ID from message.tool_calls[n].id
  • Model ignores tool results: Make sure the assistant's message (with tool_calls) is appended to history before the tool result messages
  • Infinite loop: Add a maxTurns counter — 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_call JSON — 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_calls is 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