---
title: Agentic UI in Blazor with a local LLM
date: 2026-10-07
tags: ai, llm, blazor, dotnet, lm-studio
summary: Try the new Blazor AI components with LM Studio: render tool calls as cards and let the user approve a tool before it runs.
---

In the [last Blazor post](/p/2026-08-17-local-llm-blazor/) I built a small chat against a local model. Microsoft has now released [experimental Blazor AI components](https://devblogs.microsoft.com/dotnet/build-agentic-ui-blazor/) for exactly this, and they do more than a chat box: tool calls can be rendered as your own UI, and a tool can wait for the user's approval before it runs. I tried both against LM Studio. The source is on GitHub: [pschnidrig/localAiAgenticUi](https://github.com/pschnidrig/localAiAgenticUi).

## What you need

- LM Studio with a model that supports tool calling, for example `qwen2.5-7b-instruct`. Very small models tend to answer in text instead of calling a tool. See [the LM Studio post](/p/2026-06-13-local-llm-lm-studio/) for the setup.
- The LM Studio local server running on port 1234.
- The .NET 11 SDK (RC1 or newer). The package targets `net11.0` only.
- Internet access for the weather tool. It uses [Open-Meteo](https://open-meteo.com/), which is free and needs no API key.

## 1. NuGet packages

```sh title="terminal"
dotnet add package Microsoft.AspNetCore.Components.AI --prerelease
dotnet add package Microsoft.Extensions.AI
dotnet add package Microsoft.Extensions.AI.OpenAI
dotnet add package OpenAI
```

The last three are the same as before. The new one brings the components (`ChatPage`, `MessageList`, `MessageInput`, `BlockRenderer`) and `UIAgent`, which turns the stream of an `IChatClient` into blocks the UI can render.

Add its stylesheet in `App.razor`:

```razor title="App.razor"
<link rel="stylesheet" href="@Assets["_content/Microsoft.AspNetCore.Components.AI/ai-chat.css"]" />
```

## 2. Program.cs

Same `IChatClient` as last time, plus one line: `UseFunctionInvocation()` runs the tools on the server when the model asks for them. The two tools are registered as services as well:

```csharp title="Program.cs"
builder.Services.AddChatClient(
        lmStudioClient.GetChatClient(lmStudio.ModelName).AsIChatClient())
    .UseFunctionInvocation();

builder.Services.AddScoped<NoteStore>();
builder.Services.AddHttpClient<WeatherTool>();
```

## 3. Two tools

A weather tool that gets real data from Open-Meteo, and a tool that saves a note. The weather tool needs two calls: the geocoding API turns the city name into coordinates, and the forecast API returns the current temperature and a WMO weather code:

```csharp title="WeatherTool.cs"
public sealed record WeatherReport(string City, double? TemperatureC, string Condition);

public sealed class WeatherTool(HttpClient http)
{
    public const string Name = "get_weather";

    [Description("Gets the current weather for a city.")]
    public async Task<WeatherReport> GetWeather(
        [Description("The city name, e.g. Zurich")] string city,
        CancellationToken cancellationToken = default)
    {
        var geo = await http.GetFromJsonAsync<GeocodingResponse>(
            $"https://geocoding-api.open-meteo.com/v1/search?count=1&format=json&name={Uri.EscapeDataString(city.Trim())}",
            cancellationToken);

        if (geo?.Results is not [var place, ..])
        {
            return new WeatherReport(city, null, "City not found");
        }

        var lat = place.Latitude.ToString(CultureInfo.InvariantCulture);
        var lon = place.Longitude.ToString(CultureInfo.InvariantCulture);
        var forecast = await http.GetFromJsonAsync<ForecastResponse>(
            $"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&current=temperature_2m,weather_code",
            cancellationToken);

        var name = place.Country is { } country ? $"{place.Name}, {country}" : place.Name;
        return new WeatherReport(
            name,
            forecast?.Current is { } current ? Math.Round(current.Temperature, 1) : null,
            forecast?.Current is { } c ? Describe(c.WeatherCode) : "Unknown");
    }

    // Describe() maps the WMO code to text ("Clear sky", "Rain", ...),
    // plus small private records for the two JSON responses.
}
```

The method is async and takes a `CancellationToken`. `AIFunctionFactory` handles both: it awaits the task and passes the token through, so the model only sees the `city` parameter.

```csharp title="NoteStore.cs"
[Description("Saves a note for the user.")]
public string SaveNote(string title, string content)
{
    notes.Add(new Note(title, content, DateTimeOffset.Now));
    Changed?.Invoke();
    return $"Saved note '{title}'.";
}
```

## 4. The agent

Each page gets its own `UIAgent`. Wrapping a tool in `ApprovalRequiredAIFunction` is all it takes to make it wait for the user. Only `save_note` is wrapped, because it changes something. The weather lookup just reads data, so it runs right away.

```csharp title="Home.razor"
agent = new UIAgent(ChatClient, options =>
{
    options.ChatOptions = new ChatOptions
    {
        Instructions = "You are a helpful assistant. Use the tools when they fit the question.",
        Tools =
        [
            AIFunctionFactory.Create(Weather.GetWeather, WeatherTool.Name),
            new ApprovalRequiredAIFunction(AIFunctionFactory.Create(Notes.SaveNote, NoteStore.Name))
        ]
    };
    options.AddGeneratedToolBlocks();
});
```

`Weather` and `Notes` are injected into the page (`@inject WeatherTool Weather`, `@inject NoteStore Notes`). `AddGeneratedToolBlocks()` comes from a source generator in the package. More on that in the next step.

## 5. A tool call as a card

To get a weather card, declare a typed block. The generator matches the tool name, and fills the properties from the call arguments and from the JSON result:

```csharp title="WeatherBlock.cs"
[ToolBlock(WeatherTool.Name)]
public partial class WeatherBlock : FunctionInvocationContentBlock
{
    [ToolParameter(Name = "city")]
    public string? City { get; set; }

    [ToolResult(Name = "temperatureC")]
    public double? TemperatureC { get; set; }

    [ToolResult(Name = "condition")]
    public string? Condition { get; set; }
}
```

The class has to be `partial`. The result names are camelCase because that is how `AIFunctionFactory` serializes the returned record. `TemperatureC` is nullable twice over: it is empty until the result arrives, and stays empty when the city is not found.

Then tell `ChatPage` how to render it:

```razor title="Home.razor"
<ChatPage Agent="agent" Placeholder="Ask something...">
    <MessageListContent>
        <BlockRenderer TBlock="WeatherBlock" Context="weather">
            <div class="tool-card">
                <div class="tool-card-title">Weather · @weather.City</div>
                @if (weather.HasResult)
                {
                    @if (weather.TemperatureC is { } temperature)
                    {
                        <div class="tool-card-value">@temperature °C</div>
                    }
                    <div>@weather.Condition</div>
                }
                else
                {
                    <div>Looking it up…</div>
                }
            </div>
        </BlockRenderer>
    </MessageListContent>
</ChatPage>
```

## 6. Asking before a tool runs

When the model calls `save_note`, the conversation stops and a `FunctionApprovalBlock` appears. It has the tool name, the arguments and `Approve()` / `Reject()`:

```razor title="Home.razor"
<BlockRenderer TBlock="FunctionApprovalBlock" Context="approval">
    <div class="tool-card approval">
        <div>The model wants to run <code>@approval.ToolName</code></div>
        @if (approval.Arguments is { } args)
        {
            @foreach (var (name, value) in args)
            {
                <div>@name: @value</div>
            }
        }
        @if (approval.Status == ApprovalStatus.Pending)
        {
            <button @onclick="approval.Approve">Approve</button>
            <button @onclick='() => approval.Reject("The user rejected this.")'>Reject</button>
        }
        else
        {
            <div>@approval.Status</div>
        }
    </div>
</BlockRenderer>
```

On *Approve* the tool runs and the model continues with the result. On *Reject* the model gets "Tool call invocation rejected" plus your reason, and can answer accordingly.

The approval card works for any tool. If you'd rather ask before the city name goes to Open-Meteo, wrap the weather tool the same way, and nothing else has to change:

```csharp title="Home.razor"
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(Weather.GetWeather, WeatherTool.Name)),
```

## Result

Ask "What's the weather in Zurich?" and you get a card with the current weather instead of a sentence. Ask it to save a note and nothing happens until you click *Approve*. The model runs locally; only the weather lookup goes online, and all it sends to Open-Meteo is the city name.

A few things I noticed:

- It is a preview. The API can still change, and it needs .NET 11.
- Tool calling depends on the model. If it answers in text instead of calling the tool, try a bigger one.
- `ChatPage` streams by default, so the typing effect I skipped last time is now included.

The package can do more: typed shared state between agent and UI, UI actions that run in the browser, and AG-UI for remote agents. That is for another post.
