A language model has no idea what today's weather in the Serengeti is like, nor how many lions still roam the park - all it can really do is arrange words. Function calling hands it tools: your own Python functions, which it can reach for whenever a question goes beyond what it knows. Despite the name it has nothing to do with a telephone call center and nothing to do with a type of database - it is the ability of AI to decide to invoke external functions.
Before you write a single line of code, get one thing straight - it is the source of every misunderstanding around function calling. The model never runs your function by itself. All it does is say: "I want to call
get_weather with the argument Serengeti". The running is your job. That is exactly where this differs from ordinary calling functions in Python code: the code is still yours, but the decision to reach for it belongs to the model. It is a conversation in turns:tool_calls instead of a normal answer.We will walk through those turns one at a time. We start with how to describe a tool, because the model never sees your code - only its description.
1from openai import OpenAI
2import json
3
4client = OpenAI()
5
6tools = [
7 {
8 "type": "function",
9 "function": {
10 "name": "get_weather",
11 "description": "Retrieves weather for a Safari location",
12 "parameters": {
13 "type": "object",
14 "properties": {
15 "location": {
16 "type": "string",
17 "description": "Location name, e.g. 'Serengeti'"
18 }
19 },
20 "required": ["location"]
21 }
22 }
23 }
24]Look carefully at what you are reading: in the OpenAI API you define tools as a list of dictionaries describing functions and parameters - not as separate JSON files, not as Python classes, and definitely not as npm modules, which belong to an entirely different ecosystem. The shape resembles JSON, but it lives inside your Python code as an ordinary list, which is why a real Safari assistant would simply carry more entries in it: one for the weather, another for animal data. And this is only the description of a tool, never its behaviour. The
description fields matter most - the model reads them to decide when to reach for the function and what to put in the argument. The required section states which arguments cannot be left out.Now we send the question together with the list of tools. Read the response closely, because it is not an answer meant for the user.
1response = client.chat.completions.create(
2 model="gpt-5",
3 messages=[{"role": "user", "content": "What is the weather in Serengeti?"}],
4 tools=tools,
5 tool_choice="auto"
6)
7
8message = response.choices[0].message
9if message.tool_calls:
10 for tool_call in message.tool_calls:
11 function_name = tool_call.function.name
12 arguments = json.loads(tool_call.function.arguments)
13 print(f"Model wants to call: {function_name}({arguments})")Instead of a finished reply, the model returned
tool_calls - a list of requests to invoke something. The arguments arrive as JSON text, which is why json.loads converts them into a Python dictionary. At this stage nothing has actually happened yet - the model has merely stated what it wants. The setting tool_choice="auto" leaves that judgement to it: it may reach for a tool, or answer immediately when the question needs no help at all. Execution is our next step.The model knows the function name only as a piece of text. So we need the real implementation, plus a way of getting from that text to the right code.
1def get_weather(location: str) -> dict:
2 # In a real application - a call to a weather API
3 return {"location": location, "temperature": 28, "conditions": "Sunny"}
4
5available_functions = {
6 "get_weather": get_weather
7}The
available_functions dictionary is the bridge between the world of the model and your code: the model hands you the string "get_weather", and you use that string to pull out the genuine function. You decide what truly runs - the model can reach nothing you have not placed in this mapping. Notice that this is the natural spot for a safety check, since a name that is missing from the dictionary simply never gets executed.Now watch the whole conversation: the question, the model request, the execution, handing the result back and the final answer. Follow how the
messages list grows with every turn - defining a tool and handling the function call made by the model is the entire skill.1def chat_with_tools(user_message: str):
2 messages = [{"role": "user", "content": user_message}]
3
4 response = client.chat.completions.create(
5 model="gpt-5", messages=messages, tools=tools
6 )
7 message = response.choices[0].message
8
9 if message.tool_calls:
10 messages.append(message) # store the model request
11
12 for tool_call in message.tool_calls:
13 name = tool_call.function.name
14 args = json.loads(tool_call.function.arguments)
15 result = available_functions[name](**args) # you execute it
16
17 tool_response = {
18 "role": "tool",
19 "tool_call_id": tool_call.id,
20 "content": json.dumps(result) # hand the result back
21 }
22 messages.append(tool_response)
23
24 final = client.chat.completions.create(model="gpt-5", messages=messages)
25 return final.choices[0].message.content
26
27 return message.contentWhat matters here are the two
create calls: the first, in which the model asks for a tool, and the second - once the result has been added - in which the model composes an answer for a human. Every result travels back into the conversation as a message with the role "tool" carrying a tool_call_id, and it gets there through messages.append(tool_response). That is how you tell the model "here is the output of exactly the function you asked for". Without that second turn the user would receive raw JSON instead of a sentence.Steering the turns by hand gets tedious fast. LangChain packs the entire loop into an agent - you only mark your functions as tools, and it runs the conversation for you.
1from langchain.tools import tool
2from langchain.agents import create_tool_calling_agent, AgentExecutor
3from langchain_openai import ChatOpenAI
4from langchain_core.prompts import ChatPromptTemplate
5
6@tool
7def calculate_distance(from_loc: str, to_loc: str) -> str:
8 """Calculates the distance between Safari locations."""
9 return f"Distance from {from_loc} to {to_loc}: 150 km"
10
11llm = ChatOpenAI(model="gpt-5")
12prompt = ChatPromptTemplate.from_messages([
13 ("system", "You are a helpful Safari assistant."),
14 ("human", "{input}"),
15 ("placeholder", "{agent_scratchpad}")
16])
17
18agent = create_tool_calling_agent(llm, [calculate_distance], prompt)
19executor = AgentExecutor(agent=agent, tools=[calculate_distance], verbose=True)
20executor.invoke({"input": "How far is it from Nairobi to Serengeti?"})Start with the first line, because everything else depends on it: the decorator arrives through
from langchain.tools import tool - those four pieces in that exact order. The @tool decorator then does what we wrote out by hand earlier: it builds the model-facing description from the docstring, and the argument list from the signature. AgentExecutor drives every turn from the previous example on your behalf, round after round, until the model decides it finally has the full picture.Remember one sentence from this lesson, @name: function calling is not "the AI runs your code", it is the model asking, while you execute and hand the result back. Everything else - OpenAI or LangChain - is only a more comfortable way of writing down that same conversation in turns.