Function Calling in C# with IChatClient and ASP.NET Core | Day 7
Our AI application can now chat, remember conversations, stream responses, and return structured data.
But it still has an important limitation.
Suppose a user asks:
How many Wireless Mouse units do we currently have in stock?The model should not guess.
Current stock belongs to our application or database. Instead of expecting the model to know it, we want the model to request an approved C# function:
User
↓
AI Model
↓
Needs Current Stock
↓
C# Function
↓
Application Service
↓
Real Data
↓
AI Model
↓
Final AnswerThis is function calling, also commonly called tool calling.
In Day 7 of our C# AI tutorial series, we will connect an AI model to real C# application capabilities using Microsoft.Extensions.AI.
We will learn how to:
create AI-callable C# functions
use
AIFunctionFactory.Createexpose tools through
ChatOptions.Toolsconfigure
.UseFunctionInvocation()connect AI tools to application services
validate AI-generated function arguments
protect authorization and tenant boundaries
distinguish read tools from write tools
avoid giving AI unrestricted database access
By the end, our AI assistant will be able to retrieve real inventory information instead of inventing it.
Previously in This Series
We have been building one practical C# AI application step by step.
Day 1: Build Your First AI Application with C# and .NET
Connected C# to an AI model.
Day 2: Understanding IChatClient
Introduced the provider-independent AI abstraction.
Day 3: Build a Reusable AI Chat Service in ASP.NET Core
Created an ASP.NET Core API with dependency injection.
Day 4: Add Conversation Memory
Added conversation IDs and multi-turn history.
Day 5: Stream AI Responses
Streamed generated content from the model to the browser.
Day 6: Structured AI Output
Converted AI responses into strongly typed C# data.
Today we move from:
Application
↓
Data
↓
AI
↓
Responseto:
AI
↓
Requests Approved Capability
↓
C# Function
↓
Application Data
↓
AI
↓
Final ResponseWhat Is Function Calling in AI?
Function calling allows a model to request that the application execute a predefined tool.
Suppose the user asks:
What is the current stock of Wireless Mouse?The model should not have direct database access.
Instead, our application exposes a capability such as:
GetProductStockAsync("Wireless Mouse")The model can determine that it needs this function.
Our application executes it and might return:
{
"productName": "Wireless Mouse",
"currentStock": 4,
"reorderLevel": 10
}The model can then answer:
Wireless Mouse currently has 4 units in stock.
Its reorder level is 10, so stock is below the configured threshold.The important point is that 4 came from our application data, not from the model's guess.
Function Calling Does Not Give AI Direct Access to C#
The model does not receive permission to execute arbitrary C# code.
The actual relationship is:
Developer
↓
Defines Approved Tools
↓
Model Sees Tool Metadata
↓
Model Requests a Tool
↓
Application Executes ItThe application remains in control of which capabilities exist and what they are allowed to do.
Function Calling vs Structured Output
Day 6 introduced structured output.
Structured output answers:
What structure should the model return?
For example:
{
"riskLevel": "High",
"recommendedReorderQuantity": 20
}Function calling answers:
What application capability should the model request?
For example:
GetProductStock("Wireless Mouse")They can also work together:
Function Call
↓
Retrieve Real Data
↓
AI Analysis
↓
Structured ResultFunction Calling vs RAG
RAG usually retrieves relevant information from a knowledge source:
Question
↓
Search / Retrieval
↓
Relevant Content
↓
AI ModelFunction calling lets the model request an application capability:
Question
↓
AI Model
↓
C# Tool
↓
Application Service
↓
Database / API
↓
Tool Result
↓
AI ModelA real AI application may use both.
Our Day 7 Inventory Example
We will create one safe, read-only tool:
get_product_stockThe user can ask:
How many Wireless Mouse units do we have?Our architecture becomes:
User
↓
Inventory Assistant
↓
IChatClient
↓
AI Model
↓
get_product_stock
↓
Inventory Service
↓
Real Inventory Data
↓
AI Model
↓
Final AnswerWe will intentionally start with a read-only capability.
The model cannot change stock, delete products, update prices, or create purchase orders.
Step 1: Create the Inventory Data Model
Create:
Models/ProductStockInfo.csAdd:
public sealed class ProductStockInfo
{
public string ProductName { get; set; }
= string.Empty;
public int CurrentStock { get; set; }
public int ReorderLevel { get; set; }
}This represents the data our inventory service returns.
Step 2: Create the Inventory Service
AI tools should not contain database logic directly.
Create:
Services/IInventoryService.csAdd:
public interface IInventoryService
{
Task<ProductStockInfo?> GetProductStockAsync(
string productName,
CancellationToken cancellationToken = default);
}In a real application, this service could use:
Entity Framework Core
Dapper
SQL Server
PostgreSQL
an ERP API
another internal service
For Day 7, we will use in-memory data so we can focus specifically on function calling.
Step 3: Implement the Inventory Service
Create:
Services/InventoryService.csAdd:
public sealed class InventoryService
: IInventoryService
{
private static readonly List<ProductStockInfo>
Products =
[
new()
{
ProductName = "Wireless Mouse",
CurrentStock = 4,
ReorderLevel = 10
},
new()
{
ProductName = "Mechanical Keyboard",
CurrentStock = 18,
ReorderLevel = 8
},
new()
{
ProductName = "USB-C Hub",
CurrentStock = 0,
ReorderLevel = 5
}
];
public Task<ProductStockInfo?> GetProductStockAsync(
string productName,
CancellationToken cancellationToken = default)
{
ProductStockInfo? product =
Products.FirstOrDefault(
x => string.Equals(
x.ProductName,
productName,
StringComparison.OrdinalIgnoreCase));
return Task.FromResult(product);
}
}Later, this implementation can become:
InventoryService
↓
EF Core / Dapper
↓
PostgreSQL / SQL Serverwithout changing the AI-facing architecture.
Step 4: Register the Inventory Service
In Program.cs:
builder.Services.AddScoped<
IInventoryService,
InventoryService>();Now the service is available through ASP.NET Core dependency injection.
Step 5: Create the AI Tool
Create:
AI/InventoryTools.csAdd:
using System.ComponentModel;
public sealed class InventoryTools
{
private readonly IInventoryService
_inventoryService;
public InventoryTools(
IInventoryService inventoryService)
{
_inventoryService =
inventoryService;
}
[Description(
"Gets the current stock and reorder level for a product.")]
public async Task<ProductStockInfo?> GetProductStockAsync(
[Description(
"The exact product name to look up.")]
string productName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(productName))
{
throw new ArgumentException(
"Product name is required.",
nameof(productName));
}
if (productName.Length > 200)
{
throw new ArgumentException(
"Product name is too long.",
nameof(productName));
}
return await _inventoryService
.GetProductStockAsync(
productName.Trim(),
cancellationToken);
}
}The tool creates a controlled boundary:
AI
↓
InventoryTools
↓
IInventoryService
↓
Business DataNotice that we validate productName.
Function arguments originate from the AI system and should be treated as untrusted input.
Why Tool Descriptions Matter
We added:
[Description(
"Gets the current stock and reorder level for a product.")]The model needs to understand what a tool does and when it is appropriate.
Compare:
Does inventory stuff.with:
Gets the current stock and reorder level for a product.Clear names and descriptions make tool selection more reliable.
Step 6: Create an AIFunction
Microsoft.Extensions.AI provides AIFunctionFactory for turning .NET methods into AI-callable functions.
For example:
AIFunction getProductStock =
AIFunctionFactory.Create(
_inventoryTools.GetProductStockAsync,
name: "get_product_stock",
description:
"Gets current stock and reorder level for a product.");Conceptually:
C# Method
↓
AIFunctionFactory
↓
AIFunction
↓
Tool Available to ModelUsing an explicit AI-facing name lets us keep the normal .NET method name:
GetProductStockAsyncwhile exposing:
get_product_stockto the model.
Step 7: Enable Automatic Function Invocation
Providing a function definition is only part of the process.
If the model requests:
get_product_stock(
productName = "Wireless Mouse")something must execute that function and send its result back to the model.
Configure the IChatClient pipeline with:
.AsBuilder()
.UseFunctionInvocation()
.Build();For example:
using Microsoft.Extensions.AI;
using OpenAI;
builder.Services.AddSingleton<IChatClient>(
sp =>
{
string apiKey =
builder.Configuration["AI:ApiKey"]
?? throw new InvalidOperationException(
"AI:ApiKey is not configured.");
string model =
builder.Configuration["AI:Model"]
?? throw new InvalidOperationException(
"AI:Model is not configured.");
IChatClient client =
new OpenAIClient(apiKey)
.GetChatClient(model)
.AsIChatClient();
return client
.AsBuilder()
.UseFunctionInvocation()
.Build();
});The function-invocation middleware can now handle the common tool-calling loop.
How Automatic Function Invocation Works
Conceptually:
User Message
↓
AI Model
↓
Function Requested
↓
Function Invocation Middleware
↓
AIFunction Executes
↓
Function Result
↓
AI Model
↓
Final AnswerWithout automatic invocation, your application would need to inspect function-call content, execute the matching function, add the result to the conversation, and call the model again.
For our current scenario, middleware keeps that infrastructure out of the business service.
Step 8: Create the Inventory Assistant Service
Create:
Services/IInventoryAssistantService.csAdd:
public interface IInventoryAssistantService
{
Task<string> AskAsync(
string message,
CancellationToken cancellationToken = default);
}Then create:
Services/InventoryAssistantService.csAdd:
using Microsoft.Extensions.AI;
public sealed class InventoryAssistantService
: IInventoryAssistantService
{
private const string SystemPrompt =
"""
You are an inventory assistant.
Use the available inventory tool whenever
current inventory information is required.
Never invent current stock quantities.
If a product cannot be found, clearly say so.
The available inventory tools are read-only.
Never claim that inventory has been changed.
""";
private readonly IChatClient _chatClient;
private readonly InventoryTools _inventoryTools;
private readonly ILogger<InventoryAssistantService>
_logger;
public InventoryAssistantService(
IChatClient chatClient,
InventoryTools inventoryTools,
ILogger<InventoryAssistantService> logger)
{
_chatClient = chatClient;
_inventoryTools = inventoryTools;
_logger = logger;
}
public async Task<string> AskAsync(
string message,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(message))
{
throw new ArgumentException(
"Message is required.",
nameof(message));
}
AIFunction getProductStock =
AIFunctionFactory.Create(
_inventoryTools.GetProductStockAsync,
name: "get_product_stock",
description:
"Gets current stock and reorder level for a product.");
var options = new ChatOptions
{
Tools =
[
getProductStock
]
};
var messages =
new List<ChatMessage>
{
new(
ChatRole.System,
SystemPrompt),
new(
ChatRole.User,
message)
};
_logger.LogInformation(
"Sending inventory assistant request.");
ChatResponse response =
await _chatClient.GetResponseAsync(
messages,
options,
cancellationToken);
return response.Text;
}
}This service does not know how stock is stored.
Its responsibility is coordinating:
User Request
+
AI Client
+
Available ToolsThe actual inventory logic stays behind IInventoryService.
Step 9: Register the Tool and Assistant
In Program.cs:
builder.Services.AddScoped<InventoryTools>();
builder.Services.AddScoped<
IInventoryAssistantService,
InventoryAssistantService>();Our dependency flow becomes:
InventoryAssistantService
↓
InventoryTools
↓
IInventoryService
↓
Inventory DataStep 10: Create the API Request
Create:
Models/InventoryAssistantRequest.csAdd:
using System.ComponentModel.DataAnnotations;
public sealed class InventoryAssistantRequest
{
[Required]
[StringLength(
2000,
MinimumLength = 1)]
public string Message { get; set; }
= string.Empty;
}The HTTP request is validated before it reaches the AI service.
Step 11: Create the Controller
Create:
Controllers/InventoryAssistantController.csAdd:
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/inventory-assistant")]
public sealed class InventoryAssistantController
: ControllerBase
{
private readonly IInventoryAssistantService
_assistantService;
public InventoryAssistantController(
IInventoryAssistantService assistantService)
{
_assistantService =
assistantService;
}
[HttpPost("ask")]
public async Task<ActionResult<object>> AskAsync(
[FromBody]
InventoryAssistantRequest request,
CancellationToken cancellationToken)
{
string answer =
await _assistantService.AskAsync(
request.Message,
cancellationToken);
return Ok(new
{
answer
});
}
}The endpoint is:
POST /api/inventory-assistant/askStep 12: Test Function Calling
Send:
{
"message": "How many Wireless Mouse units do we currently have?"
}The flow becomes:
User Question
↓
AI Model
↓
Needs Current Stock
↓
get_product_stock
↓
InventoryService
↓
CurrentStock = 4
ReorderLevel = 10
↓
AI Model
↓
Final AnswerA possible API response is:
{
"answer": "Wireless Mouse currently has 4 units in stock. Its reorder level is 10, so stock is below the configured threshold."
}The wording can vary.
The important fact is that the stock quantity came from our application.
What Happens Behind the Scenes?
The function-calling cycle contains several steps.
1. The Application Sends the Message and Tool
The model receives the user's question together with metadata for:
get_product_stock2. The Model Requests the Tool
Conceptually, it requests:
{
"function": "get_product_stock",
"arguments": {
"productName": "Wireless Mouse"
}
}3. The Application Executes the Approved Function
The invocation middleware matches the request to our AIFunction.
That eventually calls:
GetProductStockAsync(
"Wireless Mouse")4. The Tool Returns Real Data
For example:
{
"productName": "Wireless Mouse",
"currentStock": 4,
"reorderLevel": 10
}5. The Model Generates the Final Answer
The tool result becomes available to the model, which can explain it naturally.
With automatic function invocation configured, this loop can happen behind one application-level:
GetResponseAsync(...)call.
Why the AI Should Not Query the Database Directly
A tempting architecture is:
AI
↓
DatabaseA better boundary is:
AI
↓
Approved Tool
↓
Application Service
↓
Authorization / Business Rules
↓
DatabaseThe application should remain responsible for:
data access
authorization
tenant isolation
validation
business rules
logging
auditing
The model receives capabilities, not unrestricted infrastructure access.
Treat Tool Arguments as Untrusted Input
The model may generate:
productName = "Wireless Mouse"but AI-generated arguments should still be validated.
Our tool checks:
string.IsNullOrWhiteSpace(productName)and:
productName.Length > 200Other tools may need validation for:
identifiers
quantities
dates
financial amounts
search filters
URLs
status transitions
Function calling does not replace application validation.
Keep Security Context Outside the Model
Suppose this becomes a multi-tenant inventory application.
The model might request:
get_product_stock(productId = 123)The application should obtain the current tenant from trusted authentication context.
Do not rely on the model to provide:
TenantId
UserId
Role
PermissionA database query should conceptually enforce:
Authenticated Tenant
+
Product ID
↓
Authorized QueryThe same principle applies to user permissions.
A model's request to execute a tool is not authorization.
Prefer Stable IDs When Appropriate
Product names are convenient for our tutorial.
Production systems often have stable identifiers such as:
ProductId
SKU
WarehouseIdFor example:
GetProductStockAsync(
Guid productId,
Guid warehouseId)can be less ambiguous than a product-name lookup.
If the user only knows the name, the tool workflow could be:
search_products
↓
Product ID
↓
get_product_stockThis becomes useful as the tool set grows.
Read Tools vs Write Tools
Our current tool only reads information:
get_product_stockRead-only tools are a good starting point.
Other examples include:
search_products
get_order_status
get_invoice_details
get_customer_balanceWrite tools have greater consequences:
create_purchase_order
update_product_price
cancel_order
issue_refundFor write operations, a stronger flow is:
AI Requests Action
↓
Validate Arguments
↓
Check Authorization
↓
Check Business Rules
↓
Approval if Required
↓
ExecuteThe AI can request an action, but the application remains the authority.
Return Clear Tool Results
Our tutorial returns:
ProductStockInfo?and uses null when the product does not exist.
For larger applications, an explicit result contract can be clearer:
public sealed class ProductStockLookupResult
{
public bool Found { get; set; }
public string? ProductName { get; set; }
public int? CurrentStock { get; set; }
public int? ReorderLevel { get; set; }
}A missing product can then return:
{
"found": false,
"productName": null,
"currentStock": null,
"reorderLevel": null
}This makes tool results easier for both application code and the model to interpret.
Adding Multiple AI Tools
Our assistant currently exposes one capability.
Later we could add:
search_products
get_product_stock
get_recent_sales
get_supplier_informationThen:
var options = new ChatOptions
{
Tools =
[
searchProducts,
getProductStock,
getRecentSales
]
};A question such as:
Is Wireless Mouse selling quickly,
and should we reorder it?might require both stock and recent-sales information.
However, more tools are not automatically better.
Prefer a focused tool set with clear, non-overlapping responsibilities.
Function Calling with Conversation Memory
Day 4 introduced conversation memory.
Consider:
User:
What is the stock of Wireless Mouse?
Assistant:
There are 4 units.
User:
What about USB-C Hub?Conversation history helps the model understand what:
What about...means.
The tool provides the current data.
So:
Conversation Memory
+
Function Calling
↓
Context-Aware AssistantMemory provides conversational context.
Tools provide application capabilities.
Function Calling with Streaming and Structured Output
The concepts from previous days can also be combined.
With streaming:
User
↓
Model
↓
Tool Call
↓
Tool Result
↓
Model
↓
Streaming ResponseWith structured output:
User
↓
Tool Call
↓
Real Data
↓
AI Analysis
↓
Structured C# ResultFor Day 7, we keep the final response simple so the function-calling mechanics remain easy to understand.
Function Calling Is a Building Block for Agents
Function calling gives the model access to approved capabilities.
More advanced agent systems can add:
multiple tools
workflow state
repeated tool use
planning
memory
approvals
checkpoints
background execution
So function calling is an important building block, but it does not automatically make every application an autonomous agent.
Handle Tool Errors Carefully
Suppose the inventory database becomes unavailable.
Avoid exposing raw exceptions to the model if they contain internal details such as:
database server names
connection information
file paths
internal service names
Log appropriate technical details internally.
Expose a controlled result such as:
Inventory information is temporarily unavailable.This keeps internal infrastructure details out of AI-facing responses.
Avoid Overpowered Tools
Prefer:
get_product_stockover generic capabilities such as:
execute_sql
run_command
delete_any_record
call_any_urlNarrow tools are easier to:
validate
authorize
test
audit
monitor
The goal is not to give the model maximum power.
The goal is to give it the minimum capability required for the task.
Suggested Project Structure After Day 7
Our project can now look like:
CSharpAiApi
│
├── AI
│ └── InventoryTools.cs
│
├── Controllers
│ ├── AiController.cs
│ ├── InventoryAiController.cs
│ └── InventoryAssistantController.cs
│
├── Models
│ ├── AiChatResult.cs
│ ├── AiConversation.cs
│ ├── AiStreamContext.cs
│ ├── ChatRequest.cs
│ ├── InventoryAnalysisRequest.cs
│ ├── InventoryAnalysisResult.cs
│ ├── InventoryAssistantRequest.cs
│ ├── InventoryRiskLevel.cs
│ ├── ProductStockInfo.cs
│ └── StockStatus.cs
│
├── Services
│ ├── IAiChatService.cs
│ ├── AiChatService.cs
│ ├── IConversationStore.cs
│ ├── InMemoryConversationStore.cs
│ ├── IInventoryAiService.cs
│ ├── InventoryAiService.cs
│ ├── IInventoryAssistantService.cs
│ ├── InventoryAssistantService.cs
│ ├── IInventoryService.cs
│ └── InventoryService.cs
│
├── Program.cs
└── appsettings.jsonThe important architectural boundary is:
AI
↓
Approved Tools
↓
Application Services
↓
Business DataCommon Function Calling Problems
The Model Never Calls the Function
Check that:
the selected model supports tool calling
the tool is included in
ChatOptions.Toolsthe tool name and description are clear
.UseFunctionInvocation()is configured for automatic invocationthe provider adapter supports the capability
the user's request actually requires the tool
A model does not need to call a tool simply because one is available.
The Function Is Requested but Not Executed
Providing tools and automatically executing them are separate concerns.
For automatic invocation, verify that the client pipeline includes:
.AsBuilder()
.UseFunctionInvocation()
.Build();Otherwise, your application may need to process function-call content manually.
The Model Invents Current Data
The system prompt should explicitly say:
Use the inventory tool whenever current inventory information is required.
Never invent current stock quantities.More importantly, business decisions should rely on verified application data rather than unsupported model statements.
Invalid Function Arguments
Always validate arguments before passing them to your application services.
AI-generated input should not bypass the same boundaries you would apply to other external input.
Production Checklist
Before using function calling in production, review:
tool names and descriptions
argument validation
result contracts
authentication
authorization
tenant isolation
read vs write operations
business rules
approval requirements
error handling
timeout and cancellation
invocation limits
logging and auditing
sensitive-data exposure
provider and model support
AI usage and cost
Function calling is powerful precisely because it connects model decisions to real application capabilities.
Keep that boundary controlled.
What We Built Today
Before Day 7:
Application Data
↓
AI
↓
ResponseNow:
User
↓
AI Model
↓
Tool Request
↓
AIFunction
↓
C# Application Service
↓
Business Data
↓
Tool Result
↓
AI Model
↓
Final ResponseWe added:
IInventoryServiceInventoryServiceInventoryToolsAIFunctionAIFunctionFactory.CreateChatOptions.Tools.UseFunctionInvocation()InventoryAssistantServicefunction-argument validation
read-only tool boundaries
authorization and tenant-isolation principles
Our AI application can now use real C# capabilities without receiving unrestricted access to the system.
Day 7 Checklist
Before moving to Day 8, make sure you understand:
what AI function calling means
what
AIFunctionrepresentswhat
AIFunctionFactory.Createdoeshow
ChatOptions.Toolsexposes capabilitieswhat
.UseFunctionInvocation()provideshow tool results return to the model
why AI-generated arguments require validation
why application services should sit behind AI tools
why the model should not directly access the database
why tenant and user identity must come from trusted application context
why a tool request is not authorization
the difference between read and write tools
how function calling differs from structured output and RAG
why narrow tools are safer than generic infrastructure access
What's Next: Day 8
Our inventory tool currently reads from:
private static readonly List<ProductStockInfo>That is useful for learning function calling, but real applications store business data in databases.
In Day 8: Connect AI to a Database in C# with ASP.NET Core and EF Core, we will replace the temporary list with real database access.
The architecture will become:
User
↓
AI
↓
Function Call
↓
C# Tool
↓
Application Service
↓
Entity Framework Core
↓
Database
↓
Real Business Data
↓
AI
↓
Final ResponseWe will cover:
EF Core integration
product entities
DbContextasynchronous queries
safe read-only AI data access
dependency injection
tenant filtering
avoiding arbitrary AI-generated SQL
query performance
keeping AI outside the data-access layer
That will connect our assistant to real application data while preserving the architecture and security boundaries established today.
Final Thoughts
Function calling changes what an AI application can do.
Before Day 7, the model could only work with information already provided in its request.
Now it can request an approved capability:
get_product_stockBut it still does not receive unrestricted database or application access.
We maintain a controlled path:
AI
↓
Approved Tool
↓
Validated Input
↓
Application Service
↓
Business DataThe central principle is simple:
The model can request a capability. The application remains the authority.
Day 1 connected C# to an AI model.
Day 2 introduced IChatClient.
Day 3 built a reusable ASP.NET Core architecture.
Day 4 added conversation memory.
Day 5 added response streaming.
Day 6 introduced structured output.
Day 7 connected the model to real C# application capabilities through function calling.
Next, we will connect those capabilities to a real database with Entity Framework Core.
Comments 0