Native function calling

Now let us wire everything together. The flow has 5 steps: (1) send message with tools, (2) LLM returns tool_calls, (3) add assistant message, (4) execute functions and add tool results, (5) call LLM again for final answer.

03-tool-integration.ipynb
python
import json

messages = [{"role": "user", "content": "Turn off kitchen lights and check Paris weather"}]

# Step 1: Call LLM with tool schemas
response = completion(
    model=DEFAULT_MODEL,
    messages=messages,
    tools=tools,          # Pass the JSON schemas
    tool_choice="auto"    # Let model decide when to use tools
)

response_message = response.choices[0].message
tool_calls = response_message.get("tool_calls", [])
print(f"Model wants to call {len(tool_calls)} tools")

Pass the tools list to completion(). The model returns tool_calls instead of text when it wants to use a tool.

When the LLM requests multiple tool calls at once, the tool_call_id links each result to its corresponding request. Without it, the model would not know which result belongs to which function call. Now let us execute the tools.

03-tool-integration.ipynb
python
# Step 2: Add assistant message (with tool_calls)
messages.append(response_message)

# Step 3: Execute each tool and add results
available_functions = {
    "get_weather": get_weather,
    "toggle_light": toggle_light
}

for tool_call in tool_calls:
    function_name = tool_call.function.name
    function_args = json.loads(tool_call.function.arguments)

    # Execute the function
    result = available_functions[function_name](**function_args)

    # Add tool result to messages
    messages.append({
        "role": "tool",
        "name": function_name,
        "content": result,
        "tool_call_id": tool_call.id  # Links result to request
    })

Execute each tool call, then add results back with role "tool" and the matching tool_call_id.

With the tool results added to messages, we make one final LLM call. The model sees the results and generates a natural language response for the user.

03-tool-integration.ipynb
python
# Step 4: Call LLM again with tool results
final_response = completion(
    model=DEFAULT_MODEL,
    messages=messages
)
print(final_response.choices[0].message.content)
# "I've turned off the kitchen lights and the weather
#  in Paris is 72°F and sunny."

The LLM sees the tool results and generates a natural language summary for the user.

Complete function calling flow (5 steps)

03-tool-integration.ipynb
python
def get_weather(location: str):
    return f"The weather in {location} is 72°F and sunny."

def toggle_light(room: str, status: bool):
    action = "on" if status else "off"
    return f"The lights in the {room} have been turned {action}."

Simple tool implementations. In production, these would call real APIs or control real devices.

Matching exercise: Match function calling steps to descriptions

Loading practice…

Since the model only sees the schemas you provide, it should only call tools from that list. But as a safeguard, always check if function_name exists in your available_functions dict before calling it. If it does not exist, return an error message as the tool result. The LLM can often recover from this.

Hints: Hints

Loading practice…

Compare this to the ReAct approach: no regex, no custom system prompts for formatting, no fragile text parsing. The model returns structured data that your code can reliably process. This is the industry standard for tool integration.