Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 20 – Command-line apps, hosting, DI, configuration and telemetry

Microsoft.Extensions.AI: chat clients in C#

Call chat models from C# through IChatClient, wrap them in middleware, validate structured output, compare embeddings and test it all with fakes.

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free intro, Pro: 3 of 15 minutes free
Free: IChatClient is one interface for chat models. With a pass: the other 6 sections and 3 interview answers, about 12 minutes.

What you will learn

  • Call a chat model through IChatClient with messages, roles and options, and read its response
  • Compose middleware around a chat client with ChatClientBuilder and DelegatingChatClient
  • Validate structured output against an allowlist and ranges before the app acts on it
  • Compare texts with IEmbeddingGenerator and cosine similarity
  • Test code that depends on a chat model with a fake IChatClient, without a network or a key

Before you start

What you’ll learn · Before you start

What you will learn

  • Call a chat model through IChatClient with messages, roles and options, and read its response
  • Compose middleware around a chat client with ChatClientBuilder and DelegatingChatClient
  • Validate structured output against an allowlist and ranges before the app acts on it
  • Compare texts with IEmbeddingGenerator and cosine similarity
  • Test code that depends on a chat model with a fake IChatClient, without a network or a key

Before you start

On this page

A support team wants every new ticket summarised and sorted before a person reads it, and a chat model can do both. Calling one is usually a web request; the hard parts are around it: which service, which settings, what to log, what to send, and whether the answer can be trusted. Microsoft.Extensions.AI gives .NET one set of types for all of it, so the code that uses a model does not depend on whose model it is.

You will call a chat client through its interface, wrap it in middleware, turn its answers into checked .NET values, compare texts with embeddings, and test every piece without a network, a key or a model.

IChatClient is one interface for chat models

The Microsoft.Extensions.AI.Abstractions package defines IChatClient: The Microsoft.Extensions.AI interface for chat models: GetResponseAsync sends a list of messages with options and returns a ChatResponse. Provider packages implement it, so app code and tests do not depend on one service., and packages for chat services implement it; the Microsoft.Extensions.AI package adds middleware and helpers on top. A request is a list of chat messages, each with a role: the system message holds the instructions and a user message holds the customer’s words. The service in summarise.cs takes an IChatClient in its constructor, like any other dependency, and the program registers a fake client that records what it was sent:

summarise/summarise.cs C# · summarise/summarise.cs
#:package Microsoft.Extensions.AI@10.10.0
#:package Microsoft.Extensions.Hosting@10.0.12
#:property PublishAot=false
// A ticket summariser that depends only on IChatClient. An app would register the IChatClient of a provider's package;
// this program registers FakeChatClient, which records what it is sent and answers with a fixed sentence, so the run
// needs no network, no key and no model, and prints the same lines every time.
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
FakeChatClient fake = new("Charged twice for order A-1042; wants one payment refunded.");
builder.Services.AddChatClient(fake);
builder.Services.AddSingleton<TicketSummarizer>();
using IHost host = builder.Build();

TicketSummarizer summarizer = host.Services.GetRequiredService<TicketSummarizer>();
string summary = await summarizer.SummarizeAsync(
    "Hello, I paid for order A-1042 on Monday and the money left my account twice. Please refund one payment.");

Console.WriteLine("the chat client was sent:");
foreach (ChatMessage message in fake.Received)
    Console.WriteLine($"  {message.Role}: {message.Text}");
Console.WriteLine($"with Temperature {fake.Options?.Temperature} and MaxOutputTokens {fake.Options?.MaxOutputTokens}");
Console.WriteLine($"summary: {summary}");

// The service: it builds the messages, sets the options and reads the text of the answer. It never names a model.
sealed class TicketSummarizer(IChatClient chat)
{
    public async Task<string> SummarizeAsync(string ticket, CancellationToken cancellationToken = default)
    {
        ChatResponse response = await chat.GetResponseAsync(
            [
                new ChatMessage(ChatRole.System, "Summarise the support ticket in one sentence. Keep order numbers."),
                new ChatMessage(ChatRole.User, ticket),
            ],
            new ChatOptions { Temperature = 0, MaxOutputTokens = 60 },
            cancellationToken);
        return response.Text;
    }
}

// A stand-in for a real client: it keeps the last request and returns its fixed answer.
sealed class FakeChatClient(string answer) : IChatClient
{
    public List<ChatMessage> Received { get; } = [];
    public ChatOptions? Options { get; private set; }

    public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        Received.Clear();
        Received.AddRange(messages);
        Options = options;
        return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, answer)));
    }

    public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
        throw new NotSupportedException("this fake only answers whole responses");

    public object? GetService(Type serviceType, object? serviceKey = null) => serviceType.IsInstanceOfType(this) ? this : null;

    public void Dispose() { }
}
A ticket summariser and the fake client it called Python · summarise_session.py
from session import run

# network: true. summarise.cs names Microsoft.Extensions.AI 10.10.0 and Microsoft.Extensions.Hosting 10.0.12 in its
# #:package lines, and dotnet run restores them from nuget.org into the session's own home folder. The chat client is
# a fake inside the program, so nothing else is reached: no model, no key.
run("""
cd summarise
dotnet run summarise.cs
""", network=True)

Output

$ cd summarise
$ dotnet run summarise.cs
the chat client was sent:
  system: Summarise the support ticket in one sentence. Keep order numbers.
  user: Hello, I paid for order A-1042 on Monday and the money left my account twice. Please refund one payment.
with Temperature 0 and MaxOutputTokens 60
summary: Charged twice for order A-1042; wants one payment refunded.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 summarise_session.py

The fake received exactly what the service built: the system message, the ticket as a user message, and the options, a temperature of 0 and at most 60 output tokens. The service read the answer through response.Text and never named a model, a service or a key, so the same class runs against a real client in production and against the fake in its tests. Because the fake answers at once and the same way every time, a test that uses it is fast, repeatable and costs nothing. Step through the request:

Interactive · message by message One request through IChatClient Step through the summariser's run and see what each side sends and receives.
One request through IChatClient, step 6 of 6. Messages so far: TicketSummarizer to IChatClient: GetResponseAsync; IChatClient to FakeChatClient: messages, options; FakeChatClient to TicketSummarizer: ChatResponse.TicketSummarizersummary returnedIChatClientfrom the containerFakeChatClientstands in for a modelGetResponseAsyncmessages, optionsChatResponse

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Two messages. The service builds a system message with the instructions and a user message with the ticket. The roles tell a model which text is an instruction and which is the customer's.
  2. Options for this call. Temperature 0 and MaxOutputTokens 60 travel in ChatOptions, the settings most chat services share; each client maps them to its own service.
  3. One call through the interface. GetResponseAsync goes to the IChatClient that AddChatClient registered. The service never names a model or a service.
  4. A real client or a fake. In production the registered client comes from a provider's package. Here it is FakeChatClient, which keeps the messages and options it receives.
  5. The answer. The fake returns a ChatResponse with one assistant message, and response.Text is that message's text.
  6. What the run printed. The program printed both messages and both options as the client received them, then the summary. Every run prints the same lines, because nothing random or remote takes part.

The same interface carries the rest of this lesson. The builder ChatClientBuilder wraps a client in middleware layers, such as logging, telemetry, caching or code of your own. The extension GetResponseAsync<T> asks for structured output: A model answer requested in a fixed JSON shape, here derived from a .NET type, so the app can parse it into that type. A parsed answer is still untrusted input and is validated before use., JSON that fits a .NET type, and parses the answer into it. An allowlist check accepts only the values the app knows and sends anything else to a person. A prompt injection: Text in a model's input, such as a ticket, an email or a web page, that tries to change what the model was told to do. Defences treat such text as data, limit what the model can trigger and check every answer. is text in the input, such as a ticket, that tries to change the instructions. The interface IEmbeddingGenerator turns texts into vectors that can be compared by meaning, usually with cosine similarity: How closely two vectors point the same way: their dot product divided by the product of their lengths. It is 1 for the same direction and 0 when they share nothing..

Checkpoint

Check: IChatClient, messages and fake clients

  1. Question 1 of 2 In the summariser's request, which message carried the customer's ticket?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A user message, after the system message with the instructions

    Roles tell a model which text is an instruction and which is the customer's. The run printed system first, then user with the ticket.

  2. Question 2 of 2 Why does a test of TicketSummarizer that uses FakeChatClient give the same result on every run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The fake answers at once with a fixed text, so nothing random, slow or remote takes part

    Depending on the interface lets tests swap the client. The fake also records what it was sent, so a test can check the request as well as the result.

Middleware wraps a client in layers Pro: opens with a Pro, Premium or Ultimate pass

In this part:

  • Structured output is untrusted input
  • Embeddings compare texts by meaning
  • Practise: a ticket classifier tested with fakes
  • Interview questions
    • Your support tool asks a chat model to pick an action for each ticket and runs it. A customer's ticket text got their refund approved automatically. How did it happen, and how do you redesign the tool so that ticket text can never trigger an action on its own?
    • The tests of your AI feature call the real chat service: they take minutes, fail now and then with different answers, need a key on every build machine and cost money on each run. How do you test the feature instead?
    • Your app calls one model service's SDK in forty places. You must move to another service, and add caching, telemetry and redaction of personal data along the way, without a rewrite. How do you plan it?
  • Key takeaways

2 diagrams · 8 runnable examples (C#, Python) · 3 interview answers · about 26 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Terms in this lesson

cosine similarity
How closely two vectors point the same way: their dot product divided by the product of their lengths. It is 1 for the same direction and 0 when they share nothing.
embedding
A vector of numbers computed for a piece of text, placed so that texts with similar meaning get vectors that point in similar directions. Embeddings are compared with a measure such as cosine similarity.
IChatClient
The Microsoft.Extensions.AI interface for chat models: GetResponseAsync sends a list of messages with options and returns a ChatResponse. Provider packages implement it, so app code and tests do not depend on one service.
prompt injection
Text in a model's input, such as a ticket, an email or a web page, that tries to change what the model was told to do. Defences treat such text as data, limit what the model can trigger and check every answer.
structured output
A model answer requested in a fixed JSON shape, here derived from a .NET type, so the app can parse it into that type. A parsed answer is still untrusted input and is validated before use.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.