Chapter 6: Native Functions - Calling C# Code from LLMs
Theoretical Foundations
The fundamental challenge in building robust AI agents is not the generation of text, but the reliable execution of deterministic logic. While Large Language Models (LLMs) excel at pattern recognition and natural language synthesis, they are inherently probabilistic and stateless. They cannot natively perform complex calculations, maintain transactional consistency, or interact with external systems with guaranteed precision. This is where the concept of Native Functions becomes the linchpin of AI Engineering. By bridging the gap between the probabilistic world of the LLM and the deterministic world of compiled C# code, we create a hybrid system that leverages the strengths of both.
The Probabilistic vs. Deterministic Divide
To understand the necessity of native functions, we must first appreciate the architectural schism between an LLM and a traditional software system. An LLM operates within a high-dimensional vector space, predicting the next token based on statistical likelihood. It does not "know" how to calculate the square root of a number; it merely predicts the characters that would likely follow the prompt "What is the square root of 144?" based on patterns in its training data. While it might correctly predict "12," it has no internal calculator.
Conversely, a C# application is a sequence of deterministic instructions executed by the Common Language Runtime (CLR). When a C# method is invoked, the input maps to a specific output with 100% repeatability (assuming no external state mutation). The challenge, therefore, is to allow the LLM to request the execution of a deterministic function while remaining in its conversational flow. We need a mechanism that allows the LLM to say, "I need to perform a calculation," and for the system to reply, "Here is the result," allowing the LLM to continue its reasoning.
This is analogous to a General Contractor (GC) and a Specialized Subcontractor. The GC (the LLM) understands the overall blueprint (the user's intent) and can communicate with the client. However, the GC cannot pour concrete or wire electrical circuits. Instead, the GC issues a "work order" (a function call) to the Subcontractor (the native C# function). The Subcontractor performs the specialized work with precision and returns a completion report. The GC then integrates this report into the broader project plan. Without this delegation, the GC would be forced to guess how to pour concrete, likely resulting in a structural failure.
The Mechanics of Bridging the Gap
In the context of Microsoft Semantic Kernel, the bridge between natural language and native code is built using Plugins and Kernel Functions. A plugin is a logical grouping of capabilities, while a native function is the atomic unit of execution.
The core mechanism relies on Reflection and Attributes. When we define a C# method and annotate it with the [KernelFunction] attribute, we are signaling to the Semantic Kernel runtime that this method is callable by an LLM. This is not merely a marker; it instructs the Kernel to build an internal manifest of the function's capabilities, including its name, description, and parameters.
The Semantic Kernel utilizes this metadata to perform Automatic Parameter Binding. When an LLM generates a function call (via standard JSON schema formats), the Kernel parses the arguments provided by the model. It then maps these arguments to the C# method's parameters. This mapping is sophisticated; it handles type conversion, JSON serialization, and even complex object hydration.
Consider the requirement for complex input/output types. The LLM does not natively understand C# classes or structs. It operates on text tokens. Therefore, the Kernel acts as a translator. It serializes complex C# objects into JSON for the LLM to reason about, and upon receiving a function call, it deserializes the JSON back into strongly typed C# objects. This allows us to pass rich domain models—such as CustomerOrder or FinancialReport—between the LLM and our native code.
The Role of Strong Typing and Modern C
In earlier iterations of AI integration, developers often resorted to string parsing or loosely typed dictionaries to handle function arguments. This approach is fragile and error-prone. Modern C# features allow us to define these native functions with strong typing, leveraging the compiler's safety checks.
For instance, using record types for parameter objects ensures immutability and value equality, which is crucial when dealing with stateless function invocations. The Kernel's ability to bind JSON data to these records relies on the System.Text.Json serializer, which is highly optimized for performance.
Furthermore, the use of IAsyncEnumerable<T> allows native functions to stream results back to the LLM. This is critical for long-running operations (e.g., querying a massive database) where waiting for the entire result set would cause timeouts. The LLM can receive chunks of data and process them incrementally, mimicking a real-time conversation.
Architectural Implications: The Orchestration Layer
The introduction of native functions shifts the architecture of the AI application from a simple "Prompt -> Response" loop to a sophisticated Orchestration Layer.
In Book 7, we discussed the concept of the Kernel as the central nervous system. To recap, the Kernel is the orchestrator that manages memory, plugins, and the underlying AI services. When we expose native functions to the LLM, we are essentially equipping the Kernel with a toolkit.
Intent Recognition: The LLM analyzes the user's input (e.g., "What is the total value of pending orders for customer ID 451?").
2. Function Selection: The LLM determines that the GetPendingOrders function is required to answer the query.
3. Argument Generation: The LLM generates the arguments (e.g., { "customerId": 451 }).
4. Kernel Invocation: The Semantic Kernel intercepts this function call. It looks up the corresponding native C# method.
5. Execution: The Kernel invokes the C# method, passing the deserialized arguments.
6. Result Return: The native method executes (e.g., querying a SQL database), returns a List<Order>.
7. Context Augmentation: The Kernel serializes the result and injects it back into the chat history as a "Tool Message."
8. Final Synthesis: The LLM sees the tool message, reasons over the data, and generates the final natural language response.
This architecture allows the LLM to act as a Reasoning Engine rather than a database. It offloads the heavy lifting of data retrieval and calculation to the native code, ensuring accuracy and performance.
The "Switchboard" Analogy
To visualize this relationship, imagine a telephone switchboard operator (the Semantic Kernel). The caller (the LLM) wants to connect to a specific department (a capability). The caller doesn't know the direct extension numbers; they just ask the operator to "connect me to Billing."
The operator (Kernel) consults a directory (the Plugin Manifest). This directory maps the request "Billing" to a specific physical line (the Native Function). The operator plugs the cable into the jack (Parameter Binding) and establishes the connection (Execution). The conversation that follows (Data Flow) is routed through the switchboard.
If the switchboard operator didn't have this directory (no [KernelFunction] attributes), the caller would be shouting into the void. If the mapping was manual and error-prone (loose typing), the caller might be connected to the wrong department. The native function mechanism provides the standardized directory and the reliable cabling.
Visualizing the Flow
The following diagram illustrates the flow of control and data when a native function is invoked by an LLM via the Semantic Kernel.
Edge Cases and Error Handling
Type Mismatches: If the LLM generates an argument of the wrong type (e.g., passing a string "one" to an int parameter), the Kernel's binding mechanism will throw a KernelException. It is the responsibility of the function author to handle validation or for the Kernel to provide fallback logic.
2. Complex Object Hydration: When a native function requires a complex object (e.g., SearchParameters containing nested Filters), the LLM must generate a JSON object that matches the schema exactly. The Kernel uses the JsonSerializerOptions to map snake_case or camelCase JSON properties to PascalCase C# properties, but strict adherence to the schema is required.
3. Async Operations: Native functions often involve I/O. The Kernel supports async natively, but the LLM's context window is finite. If a native function takes too long to execute, the conversation state may expire. Strategies like "deferred execution" or "polling" via native functions are required for long-running tasks.
Why This Matters for AI Engineering
The ability to call native C# code is not just a convenience; it is a requirement for production-grade AI systems. Without it, we are limited to the LLM's training data, which is static and prone to hallucination.
Access Real-Time Data: Query live databases or APIs. * Perform Calculations: Execute math, statistics, or logic that requires precision. * Manipulate State: Create files, send emails, or update records. * Enforce Security: Run sensitive logic within the safety of the backend application rather than exposing it to the prompt.
In the subsequent sections of this chapter, we will move from theory to practice. We will define the [KernelFunction] attributes, explore the serialization strategies for complex types, and demonstrate how to configure the Kernel to expose these functions to an LLM for function calling. We will see that the "magic" of AI is not in the model itself, but in the engineering of the bridge that connects it to the real world.
Basic Code Example
// ==========================================
// Native Functions: Calling C# Code from LLMs
// Basic Code Example
// ==========================================
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
// 1. Define the data structure for our native function.
// This class represents the input parameters required for the function.
// We use System.Text.Json for serialization, which is the default in modern .NET.
public class WeatherRequest
{
[JsonPropertyName("city")]
[Description("The name of the city to retrieve weather for.")]
public string City { get; set; } = string.Empty;
[JsonPropertyName("unit")]
[Description("The unit of temperature: 'Celsius' or 'Fahrenheit'. Defaults to Celsius.")]
public string Unit { get; set; } = "Celsius";
}
// 2. Define the response data structure.
// This represents the output of our native function.
public class WeatherResponse
{
[JsonPropertyName("city")]
public string City { get; set; } = string.Empty;
[JsonPropertyName("temperature")]
public double Temperature { get; set; }
[JsonPropertyName("unit")]
public string Unit { get; set; } = string.Empty;
[JsonPropertyName("condition")]
public string Condition { get; set; } = string.Empty;
}
// 3. Create the Native Plugin Class.
// This class contains the actual C# logic we want the LLM to invoke.
public class WeatherPlugin
{
// The [KernelFunction] attribute marks this method as invokable by the Semantic Kernel.
// The [Description] attribute is crucial: it tells the LLM what this function does.
[KernelFunction, Description("Retrieves the current weather for a specified city.")]
public async Task<WeatherResponse> GetWeatherAsync(
// Parameter binding: The kernel maps the LLM's arguments to these parameters.
[Description("The city to get weather for")] string city,
[Description("The unit of temperature")] string unit = "Celsius")
{
// Simulate a database or API call.
// In a real app, this would be an HttpClient call to a weather service.
await Task.Delay(100); // Simulate network latency
// Mock logic for demonstration purposes.
// Deterministic code execution happens here.
var random = new Random();
double temp = random.NextDouble() * 40 - 10; // Random temp between -10 and 30
string condition = random.Next(0, 3) switch
{
0 => "Sunny",
1 => "Cloudy",
_ => "Rainy"
};
// Convert temperature if requested (simple logic for example)
if (unit.Equals("Fahrenheit", StringComparison.OrdinalIgnoreCase))
{
temp = (temp * 9 / 5) + 32;
}
return new WeatherResponse
{
City = city,
Temperature = Math.Round(temp, 1),
Unit = unit,
Condition = condition
};
}
}
// 4. Main Execution Context
// This demonstrates how to register the plugin and invoke it via the Kernel.
class Program
{
static async Task Main(string[] args)
{
// Setup: Initialize the Kernel.
// We use a mock connector here to avoid needing real API keys for the example.
var kernel = Kernel.CreateBuilder()
.AddOpenAIChatCompletion(
modelId: "gpt-4o-mini",
apiKey: "fake-api-key-for-demo") // Placeholder
.Build();
// Step A: Import the Native Plugin.
// The Kernel scans the class for [KernelFunction] attributes.
var plugin = kernel.ImportPluginFromObject(new WeatherPlugin(), "Weather");
// Step B: Prepare arguments for the function.
// The Semantic Kernel handles JSON serialization of these arguments.
var arguments = new KernelArguments
{
["city"] = "Seattle",
["unit"] = "Celsius"
};
Console.WriteLine("--- Invoking Native Function Directly ---");
// Step C: Execute the function.
// This bypasses the LLM and calls the C# code directly.
// This is useful for testing or deterministic execution paths.
var result = await kernel.InvokeAsync(plugin["GetWeatherAsync"], arguments);
// Step D: Process the result.
// The result is automatically deserialized from JSON (or the raw object) back to a usable format.
Console.WriteLine($"Result: {result}");
// Note: In a full agentic flow, the LLM would decide to call this function.
// The flow would look like this:
// 1. User: "What's the weather in Seattle?"
// 2. LLM: Analyzes intent -> identifies 'GetWeatherAsync' function is needed.
// 3. LLM: Generates JSON arguments: { "city": "Seattle", "unit": "Celsius" }.
// 4. Semantic Kernel: Deserializes JSON -> invokes C# method.
// 5. C# Method: Executes logic -> returns WeatherResponse object.
// 6. Semantic Kernel: Serializes response -> sends to LLM.
// 7. LLM: Natural language response: "It's currently 22°C and Sunny in Seattle."
}
}
Detailed Line-by-Line Explanation
Lines 10-20: We define WeatherRequest. While the GetWeatherAsync method takes primitive parameters (string city, string unit), defining a class is a best practice for complex inputs. It allows the LLM to understand the schema better via attributes.
* [JsonPropertyName]: This attribute maps the C# property name (PascalCase) to the JSON key (snake_case or camelCase) expected by the LLM. LLMs typically generate camelCase JSON.
* [Description]: This is the most critical attribute for LLM integration. It provides the semantic context the LLM uses to decide when to use this parameter and what value to provide.
* Lines 22-34: WeatherResponse defines the shape of the data returned to the LLM. By strongly typing this, we ensure the LLM receives structured data, preventing hallucinations of weather data.
Line 38: [KernelFunction]. This attribute is the marker that tells the Semantic Kernel's reflection system, "This method is callable from the outside world (LLM or code)." Without this, the method is ignored.
* Line 40: [Description("Retrieves...")]. This description is the "hook" for the LLM. When the LLM analyzes a user's prompt, it looks at the list of available functions and their descriptions to select the most relevant one.
* Lines 43-44: The method signature.
* public async Task<WeatherResponse>: The return type is strongly typed. The Semantic Kernel automatically serializes this object into JSON for the LLM.
* Parameters: We use primitive types here for simplicity. The Kernel supports automatic binding from JSON arguments (provided by the LLM) to these C# parameters.
* string unit = "Celsius": Default values are respected. If the LLM omits the 'unit' argument, C# defaults apply.
* Lines 47-65: The Execution Logic.
* This is standard C# code. It could be database access, file I/O, or complex calculations.
* Crucial Concept: The LLM does not execute this code. It only triggers it. The security and determinism rely entirely on this C# implementation.
* The method returns a WeatherResponse object. The Semantic Kernel serializes this to JSON before passing it back to the LLM context.
Line 74: Kernel.CreateBuilder(). Initializes the orchestrator. We use AddOpenAIChatCompletion as a placeholder. In a real scenario, this connects to an actual model endpoint.
* Line 82: kernel.ImportPluginFromObject(new WeatherPlugin(), "Weather"). This is the registration step. The kernel inspects the WeatherPlugin class, finds the [KernelFunction] method, and creates a plugin named "Weather" containing the function "GetWeatherAsync".
* Line 85-89: KernelArguments. This is a dictionary-like structure holding the input data. This mimics the arguments the LLM would generate during a function call.
* Line 95: kernel.InvokeAsync(...). This is the direct invocation. It bypasses the LLM's reasoning step and executes the C# code immediately.
* Note: In a full agentic flow, you would typically call kernel.InvokeAsync(prompt) where the prompt triggers the LLM to generate the function call arguments, which the kernel then executes automatically.
Missing [KernelFunction] Attribute:
* The Mistake: Defining a public method in a plugin class but forgetting to decorate it with [KernelFunction].
* The Result: The method will not be registered as a callable function. The LLM will not see it in its available tools list, and direct invocation via kernel.InvokeAsync will throw an exception (function not found).
* Why it happens: It's easy to treat the plugin class as a standard C# class. Remember, the Semantic Kernel relies on attributes for metadata discovery.
-
Ignoring Parameter Descriptions:
- The Mistake: Leaving
[Description]attributes empty or omitting them entirely on parameters. - The Result: The LLM receives a schema with no semantic context. It may hallucinate parameter values or fail to map the user's intent to the correct function arguments.
- The Fix: Always provide clear, concise descriptions. Treat them as the "API documentation" for the LLM.
- The Mistake: Leaving
-
Complex Input Types vs. Primitives:
- The Mistake: Passing complex objects as parameters without ensuring they are properly serializable or understood by the LLM.
- The Nuance: While the Kernel supports complex types, LLMs function best with simple primitives or well-defined JSON schemas. If you pass a complex object, ensure the LLM knows how to construct it (usually via multiple simple parameters or a structured output mode).
- Recommendation: For simple functions, use primitives (
string,int,bool). For complex logic, use a single DTO (Data Transfer Object) likeWeatherRequestin the example, but be aware that the LLM must generate a JSON object matching that DTO's schema.
-
Async/Await Mismatch:
- The Mistake: Defining a synchronous method (e.g.,
voidorstring) when the operation involves I/O (database, network). - The Result: Blocking the execution thread, leading to performance issues or deadlocks in scalable applications.
- The Fix: Always prefer
async Taskorasync Task<T>for native functions. The Semantic Kernel handles asynchronous execution natively.
- The Mistake: Defining a synchronous method (e.g.,
Visualizing the Data Flow
The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon
Loading knowledge check...
Code License: All code examples are released under the MIT License. Github repo.
Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.
All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.