n4nAI

Semantic Kernel plugins tutorial: auto function calling

Build a working Semantic Kernel auto function calling setup with plugins, kernel configuration, and debugging techniques you can run today.

n4n Team4 min read838 words

Audio narration

Coming soon — every post will get a voice note here.

This semantic kernel auto function calling tutorial walks you through building a complete, runnable example that lets an LLM discover and invoke your code automatically. You’ll create plugins, configure the kernel with function calling behavior, and see exactly what the model sends and receives at each step.

Prerequisites

  • Python 3.10+
  • An OpenAI API key (or Azure OpenAI endpoint)
  • Basic familiarity with async Python

Install the Semantic Kernel package:

pip install semantic-kernel[openai]

The [openai] extra pulls in the OpenAI connector. If you prefer Azure, use [azure] instead.

Project structure

Create a minimal layout:

sk-auto-func/
├── main.py
├── plugins/
│   ├── __init__.py
│   ├── weather.py
│   └── calculator.py
└── .env

Your .env file holds the API key:

OPENAI_API_KEY=sk-...
# Optional: override model
OPENAI_CHAT_MODEL=gpt-4o-mini

Build the plugins

Semantic Kernel plugins are plain Python classes decorated with @kernel_function. The decorator extracts the function name, description, and parameter schema from type hints and docstrings.

Weather plugin

# plugins/weather.py
from semantic_kernel.functions import kernel_function
from typing import Annotated
import random

class WeatherPlugin:
    """Provides current weather for a given location."""

    @kernel_function(
        name="get_current_weather",
        description="Get the current weather for a city"
    )
    def get_current_weather(
        self,
        city: Annotated[str, "The city name, e.g. 'Seattle'"],
        unit: Annotated[str, "Temperature unit: 'celsius' or 'fahrenheit'"] = "fahrenheit"
    ) -> str:
        # Simulated data — replace with a real API call in production
        temp_f = random.randint(40, 85)
        temp_c = round((temp_f - 32) * 5 / 9)
        temp = temp_f if unit == "fahrenheit" else temp_c
        unit_label = "°F" if unit == "fahrenheit" else "°C"
        conditions = random.choice(["sunny", "cloudy", "rainy", "partly cloudy"])
        return f"{city}: {temp}{unit_label}, {conditions}"

Calculator plugin

# plugins/calculator.py
from semantic_kernel.functions import kernel_function
from typing import Annotated
import math

class CalculatorPlugin:
    """Basic arithmetic and math helpers."""

    @kernel_function(
        name="add",
        description="Add two numbers"
    )
    def add(
        self,
        a: Annotated[float, "First number"],
        b: Annotated[float, "Second number"]
    ) -> float:
        return a + b

    @kernel_function(
        name="multiply",
        description="Multiply two numbers"
    )
    def multiply(
        self,
        a: Annotated[float, "First number"],
        b: Annotated[float, "Second number"]
    ) -> float:
        return a * b

    @kernel_function(
        name="sqrt",
        description="Square root of a number"
    )
    def sqrt(
        self,
        value: Annotated[float, "Non-negative number"]
    ) -> float:
        if value < 0:
            raise ValueError("Cannot take square root of negative number")
        return math.sqrt(value)

Plugin package init

# plugins/__init__.py
from .weather import WeatherPlugin
from .calculator import CalculatorPlugin

__all__ = ["WeatherPlugin", "CalculatorPlugin"]

Configure the kernel with auto function calling

The kernel needs three things: the model client, the plugins, and a FunctionChoiceBehavior that tells the model it can call functions.

# main.py
import os
import asyncio
from dotenv import load_dotenv
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.contents import ChatHistory
from plugins import WeatherPlugin, CalculatorPlugin

load_dotenv()

async def main():
    kernel = Kernel()

    # 1. Add the chat completion service
    model_id = os.getenv("OPENAI_CHAT_MODEL", "gpt-4o-mini")
    kernel.add_service(OpenAIChatCompletion(
        service_id="default",
        ai_model_id=model_id,
        api_key=os.getenv("OPENAI_API_KEY")
    ))

    # 2. Import plugins — each public method with @kernel_function becomes a tool
    kernel.add_plugin(WeatherPlugin(), plugin_name="weather")
    kernel.add_plugin(CalculatorPlugin(), plugin_name="calculator")

    # 3. Enable auto function calling
    #    FunctionChoiceBehavior.Auto() lets the model decide when to call functions
    settings = kernel.get_prompt_execution_settings_from_service_id("default")
    settings.function_choice_behavior = FunctionChoiceBehavior.Auto()

    # 4. Create a chat history with a system prompt that encourages tool use
    history = ChatHistory()
    history.add_system_message(
        "You are a helpful assistant with access to weather and calculator tools. "
        "Use them whenever they can answer the user's question more accurately."
    )

    # 5. Run a few test turns
    user_queries = [
        "What's the weather in Denver?",
        "If it's 72°F in Denver, what's that in Celsius?",
        "What's the square root of 144?",
        "Multiply 13 by 37, then add 42."
    ]

    for query in user_queries:
        print(f"\n👤 User: {query}")
        history.add_user_message(query)

        # Invoke the kernel — this handles the function calling loop internally
        result = await kernel.invoke(
            plugin_name="chat",
            function_name="chat",
            chat_history=history,
            settings=settings
        )

        # The result is the final assistant message after any tool calls
        assistant_msg = str(result)
        print(f"🤖 Assistant: {assistant_msg}")
        history.add_assistant_message(assistant_msg)

if __name__ == "__main__":
    asyncio.run(main())

Wait — the kernel.invoke call above assumes a chat plugin exists. Semantic Kernel doesn’t ship one by default. The simpler, more direct approach is to use the chat completion service directly with the kernel’s function calling behavior attached. Let me correct that.

Corrected: use the chat completion service directly

# main.py (corrected)
import os
import asyncio
from dotenv import load_dotenv
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.contents import ChatHistory
from plugins import WeatherPlugin, CalculatorPlugin

load_dotenv()

async def main():
    kernel = Kernel()

    # 1. Add the chat completion service
    model_id = os.getenv("OPENAI_CHAT_MODEL", "gpt-4o-mini")
    chat_service = OpenAIChatCompletion(
        service_id="default",
        ai_model_id=model_id,
        api_key=os.getenv("OPENAI_API_KEY")
    )
    kernel.add_service(chat_service)

    # 2. Import plugins
    kernel.add_plugin(WeatherPlugin(), plugin_name="weather")
    kernel.add_plugin(CalculatorPlugin(), plugin_name="calculator")

    # 3. Configure auto function calling on the execution settings
    settings = kernel.get_prompt_execution_settings_from_service_id("default")
    settings.function_choice_behavior = FunctionChoiceBehavior.Auto()

    # 4. Chat loop
    history = ChatHistory()
    history.add_system_message(
        "You are a helpful assistant with access to weather and calculator tools. "
        "Use them whenever they can answer the user's question more accurately."
    )

    user_queries = [
        "What's the weather in Denver?",
        "If it's 72°F in Denver, what's that in Celsius?",
        "What's the square root of 144?",
        "Multiply 13 by 37, then add 42."
    ]

    for query in user_queries:
        print(f"\n👤 User: {query}")
        history.add_user_message(query)

        # Get response — the service handles the function calling loop
        response = await chat_service.get_chat_message_content(
            chat_history=history,
            settings=settings,
            kernel=kernel  # Pass kernel so the service can resolve functions
        )

        assistant_msg = str(response)
        print(f"🤖 Assistant: {assistant_msg}")
        history.add_assistant_message(assistant_msg)

if __name__ == "__main__":
    asyncio.run(main())

Run it

python main.py

Expected output (your temperatures will differ)

👤 User: What's the weather in Denver?
🤖 Assistant: Denver: 67°F, partly cloudy

👤 User: If it's 72°F in Denver, what's that in Celsius?
🤖 Assistant: 72°F is approximately 22.2°C.

👤 User: What's the square root of 144?
🤖 Assistant: The square root of 144 is 12.

👤 User: Multiply 13 by 37, then add 42.
🤖 Assistant: 13 × 37 = 481. Adding 42 gives 523.

Notice the second query: the model didn’t call a function — it knew the conversion formula. The fourth query triggered two function calls (multiply, then add) in a single turn. The kernel’s FunctionChoiceBehavior.Auto() manages that loop transparently.

Inspect the function calling traffic

To see what the model actually sends and receives, enable debug logging:

# Add near the top of main.py
import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("semantic_kernel").setLevel(logging.DEBUG)

Run again and you’ll see entries like:

DEBUG:semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion:
Sending request with tools: [
  {'type': 'function', 'function': {'name': 'weather-get_current_weather', ...}},
  {'type': 'function', 'function': {'name': 'calculator-multiply', ...}},
  ...
]

DEBUG:semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion:
Received tool calls: [
  {'id': 'call_abc123', 'function': {'name': 'calculator-multiply', 'arguments': '{"a":13,"b":37}'}}
]

This is invaluable when the model picks the wrong function or hallucinates arguments.

Control function choice behavior

FunctionChoiceBehavior.Auto() is the default, but you have three options:

from semantic_kernel.connectors.ai.function_choice_behavior import (
    FunctionChoiceBehavior,
    FunctionChoiceType
)

# Model decides — default
settings.function_choice_behavior = FunctionChoiceBehavior.Auto()

# Force the model to call a function (useful for "plan then execute" patterns)
settings.function_choice_behavior = FunctionChoiceBehavior.Required()

# Disable function calling entirely
settings.function_choice_behavior = FunctionChoiceBehavior.None_()

# Or restrict to specific functions by name
settings.function_choice_behavior = FunctionChoiceBehavior.Auto(
    functions=["weather-get_current_weather", "calculator-add"]
)

The functions filter is handy when you’ve registered many plugins but only want a subset available for a particular workflow.

Multiple function calls in one turn

The fourth query in our test (“Multiply 13 by 37, then add 42”) demonstrates chained calls. The model emits:

{
  "tool_calls": [
    {"id": "call_1", "function": {"name": "calculator-multiply", "arguments": "{\"a\":13,\"b\":37}"}},
    {"id": "call_2", "function": {"name": "calculator-add", "arguments": "{\"a\":481,\"b\":42}"}}
  ]
}

The kernel executes them sequentially, feeds results back, and the model produces the final answer. No extra code required — FunctionChoiceBehavior.Auto() handles the loop until the model stops emitting tool calls.

Add a plugin with async functions

Real plugins often call HTTP APIs. Make the method async and the kernel awaits it automatically:

# plugins/http_weather.py
from semantic_kernel.functions import kernel_function
from typing import Annotated
import httpx

class HttpWeatherPlugin:
    """Weather via Open-Meteo (no key required)."""

    @kernel_function(
        name="get_forecast",
        description="Get a 3-day forecast for a city"
    )
    async def get_forecast(
        self,
        city: Annotated[str, "City name"],
        latitude: Annotated[float, "Latitude"],
        longitude: Annotated[float, "Longitude"]
    ) -> str:
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.get(
                "https://api.open-meteo.com/v1/forecast",
                params={
                    "latitude": latitude,
                    "longitude": longitude,
                    "daily": "temperature_2m_max,temperature_2m_min,weathercode",
                    "timezone": "auto",
                    "forecast_days": 3
                }
            )
            resp.raise_for_status()
            data = resp.json()

        # Simplify weather codes
        code_map = {0: "clear", 1: "mainly clear", 2: "partly cloudy", 3: "overcast",
                    45: "fog", 51: "light drizzle", 61: "rain", 71: "snow"}
        days = []
        for i in range(3):
            code = data["daily"]["weathercode"][i]
            hi = data["daily"]["temperature_2m_max"][i]
            lo = data["daily"]["temperature_2m_min"][i]
            days.append(f"Day {i+1}: {code_map.get(code, 'unknown')}, {lo}{hi}°C")
        return f"{city} forecast:\n" + "\n".join(days)

Register it the same way:

from plugins.http_weather import HttpWeatherPlugin
kernel.add_plugin(HttpWeatherPlugin(), plugin_name="http_weather")

Now the model can answer “What’s the 3-day forecast for Paris?” — but you’ll need to provide lat/long or add a geocoding plugin. This reveals a common pattern: compose plugins so the model can chain them (geocode → forecast).

Filter plugins per request

In production you may want different tool sets for different users or contexts. Create a kernel per request, or clone and modify:

def build_kernel_for_user(user_tier: str) -> Kernel:
    kernel = Kernel()
    kernel.add_service(chat_service)
    
    # Everyone gets calculator
    kernel.add_plugin(CalculatorPlugin(), plugin_name="calculator")
    
    # Premium users get weather + HTTP weather
    if user_tier == "premium":
        kernel.add_plugin(WeatherPlugin(), plugin_name="weather")
        kernel.add_plugin(HttpWeatherPlugin(), plugin_name="http_weather")
    
    settings = kernel.get_prompt_execution_settings_from_service_id("default")
    settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
    return kernel

This keeps the function namespace small for the model, reducing wrong-tool selections.

Error handling and retries

Function calls can fail. The kernel surfaces exceptions as tool results with an error message. You can catch and retry at the plugin level:

# plugins/robust_calculator.py
from semantic_kernel.functions import kernel_function
from typing import Annotated
import math
import logging

logger = logging.getLogger(__name__)

class RobustCalculatorPlugin:
    @kernel_function(name="divide", description="Divide a by b")
    def divide(
        self,
        a: Annotated[float, "Numerator"],
        b: Annotated[float, "Denominator"]
    ) -> float:
        try:
            if b == 0:
                raise ValueError("Division by zero")
            return a / b
        except Exception as e:
            logger.warning(f"divide failed: {e}")
            # Return a string — the model sees this as the tool result
            return f"Error: {e}"

Returning a string error lets the model recover (“I got an error, let me try again with different arguments”) rather than crashing the turn.

Production considerations

Token usage

Each function schema adds tokens to the system prompt. With 20+ functions, you’re spending 1,500–3,000 tokens just on tool definitions. Mitigations:

  • Filter plugins per request (shown above)
  • Use concise descriptions — the model reads them
  • Group related functions into fewer plugins with clearer names

Latency

Auto function calling adds round trips: model → tool → model → tool → model. For user-facing chat, this is visible. Options:

  • Parallel function calling: OpenAI supports parallel tool calls; Semantic Kernel executes them concurrently when the model emits multiple calls in one response.
  • Streaming: Use get_streaming_chat_message_contents to show partial results while tools run in the background.
  • Cache idempotent calls: Weather for the same city within 10 minutes can be cached at the plugin level.

Observability

Log every function call with arguments, latency, and outcome:

from semantic_kernel.functions import KernelFunctionMetadata
from functools import wraps

def logged_function(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            result = await func(*args, **kwargs)
            duration = time.perf_counter() - start
            logger.info(f"{func.__name__} ok ({duration:.2f}s) args={kwargs}")
            return result
        except Exception as e:
            duration = time.perf_counter() - start
            logger.error(f"{func.__name__} error ({duration:.2f}s) args={kwargs}: {e}")
            raise
    return wrapper

Apply @logged_function above @kernel_function on your plugin methods.

Testing without an LLM

Unit test your plugins directly — they’re just Python classes:

# test_plugins.py
import pytest
from plugins.calculator import CalculatorPlugin

def test_calculator():
    calc = CalculatorPlugin()
    assert calc.add(2, 3) == 5
    assert calc.multiply(4, 5) == 20
    assert calc.sqrt(16) == 4
    with pytest.raises(ValueError):
        calc.sqrt(-1)

For integration tests, use a fake model or the MockChatCompletion from semantic_kernel.connectors.ai.open_ai (if available) to verify the kernel wires functions correctly.

Summary

You now have a working semantic kernel auto function calling tutorial setup that:

  1. Defines plugins as plain Python classes with @kernel_function
  2. Registers them with a Kernel instance
  3. Enables FunctionChoiceBehavior.Auto() on the execution settings
  4. Passes the kernel to the chat completion service so it can resolve and invoke functions
  5. Handles chained calls, async plugins, and error recovery automatically

The pattern scales: add more plugins, filter them per context, log everything, and cache where it matters. The kernel stays thin — your business logic lives in the plugins, testable and replaceable without touching the orchestration layer.

If you’re routing across multiple model providers in production, an inference gateway like n4n.ai can sit between your kernel and the providers, handling fallback and usage metering without changes to your plugin code.

Tagssemantic-kernelpluginsauto-function-callingtutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel plugins & native functions posts →