AI agents by framework / C# and .NET
How to add an AI agent to a Blazor app
An AI agent in a Blazor Web App with Interactive Server rendering is a scoped service that calls a model with tools and runs the tool calls as the signed-in user, while a component streams the reply into its state over the circuit. Microsoft.Extensions.AI provides the model client, the function-calling loop and tool approval; you supply the tools, authorization and limits. If the agent only needs to do what your pages already do, you can embed one instead.
What an agent in a Blazor app is made of
With Interactive Server rendering, component code runs on the server inside the user's circuit, the long-lived SignalR connection that carries UI events and render updates. The agent therefore needs no separate HTTP endpoint, and the model key never reaches the browser.
- An agent service: a scoped service that holds the conversation and calls the model. In Interactive Server rendering a scoped service lives as long as the circuit, so each browser tab gets its own.
- Tools:
AIFunctioninstances that wrap methods of services you already register in dependency injection, such as an order service or an EF Core repository. - The agent loop: the function-invoking chat client calls the model, runs the tools it asks for and sends the results back until the model can answer. Cap the number of iterations.
- A chat component: a routable component that streams updates into its state and re-renders as they arrive.
- Authorization and approval: the page requires a signed-in user, the tools act as the user from the circuit's authentication state, and an approval component holds back writes until the user confirms.
Ways to add an agent to Blazor
| Approach | Good for | What you take on |
|---|---|---|
| Microsoft.Extensions.AI in a scoped service | An agent inside your own components, on any provider that has an IChatClient implementation. | Tool definitions, authorization in every tool, the approval component, circuit memory, rate and cost limits. |
AI Chat Web App template (dotnet new aichatweb) | Starting a new Blazor chat app that answers from your documents with citations. | A separate starter app from a preview template; tools, authorization and approval in your existing app are still yours to add. |
| Microsoft Agent Framework | Agents with sessions, multi-agent workflows and long-running, human-in-the-loop processes. | Another layer to learn (its ASP.NET Core hosting packages are prerelease), plus everything in the first row. |
| Hosted answer-only chat widget | Questions about your docs or help center. | Keeping its content current. It does not act in your app. |
| Embedded in-app agent (Bunkhouse) | Letting signed-in users get tasks done through the pages and API you already have. | A script tag, an identity endpoint and hints for operations discovery cannot see. |
The AI app templates and the framework Microsoft Agent Framework are both built on the same IChatClient abstraction as the approach below, so tools you write now carry over.
Build an agent with Microsoft.Extensions.AI
Microsoft.Extensions.AI is the .NET abstraction for model clients. Its IChatClient interface streams responses as IAsyncEnumerable, which a component can consume directly, and its function-invoking client runs tools and handles tool approval. The examples use version 10.11 with a .NET 10 Blazor Web App and the OpenAI provider package:
dotnet package add Microsoft.Extensions.AI
dotnet package add Microsoft.Extensions.AI.OpenAIStore the key and a tool-capable model name (the Microsoft quickstart uses gpt-5) in user secrets: run dotnet user-secrets set "OpenAI:ApiKey" "..." and dotnet user-secrets set "OpenAI:Model" "...". For Azure OpenAI or Ollama, register their IChatClient instead; the rest of the code does not change.
1. Register the chat client and the agent service
The method AddChatClient registers the model client, and the method UseFunctionInvocation wraps it in the agent loop, capped at five model round trips per message by MaximumIterationsPerRequest. The agent service is registered as scoped, so its conversation belongs to one circuit and never leaks to another user.
using Microsoft.Extensions.AI;
using OpenAI;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// ...your existing authentication and services, including IOrderService
var openAI = builder.Configuration.GetRequiredSection("OpenAI");
builder.Services.AddChatClient(new OpenAIClient(openAI["ApiKey"]).GetChatClient(openAI["Model"]).AsIChatClient())
.UseFunctionInvocation(configure: f => f.MaximumIterationsPerRequest = 5);
builder.Services.AddScoped<AgentChat>();The object HttpContext is not available during interactive rendering, so the service reads the user from the authentication state provider, which Blazor fills from the authenticated connection when the circuit starts. Each tool closes over that user ID: the model can choose an order ID but never whose orders it reads or cancels. The interface IOrderService stands for your existing data layer, which should also check that the order belongs to that user.
Wrapping cancelOrder in ApprovalRequiredAIFunction stops the loop before the tool runs: the stream contains a ToolApprovalRequestContent instead of a result. The answer goes back as the message content request.CreateResponse(approved); on approval the loop runs the tool, and on denial it tells the model the call was rejected. The setting AllowMultipleToolCalls = false keeps the model to one tool call per step, because when one call in a response needs approval, every call in that response does (FunctionInvokingChatClient).
using System.Runtime.CompilerServices;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.AI;
// Scoped: in Interactive Server rendering, one instance per circuit (per browser tab), so the
// conversation lives in server memory for as long as the circuit does.
public sealed class AgentChat(IChatClient chat, AuthenticationStateProvider auth, IOrderService orders)
{
readonly List<ChatMessage> history = [];
public IAsyncEnumerable<ChatResponseUpdate> SendAsync(string text, CancellationToken ct)
{
history.Add(new(ChatRole.User, text));
return RunAsync(ct);
}
// The request object never leaves the server; the browser only sends back which button was clicked.
public IAsyncEnumerable<ChatResponseUpdate> RespondAsync(ToolApprovalRequestContent request, bool approved, CancellationToken ct)
{
history.Add(new(ChatRole.User, [request.CreateResponse(approved)]));
return RunAsync(ct);
}
async IAsyncEnumerable<ChatResponseUpdate> RunAsync([EnumeratorCancellation] CancellationToken ct)
{
// HttpContext is not available over the circuit; the user comes from the authentication state.
var user = (await auth.GetAuthenticationStateAsync()).User;
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new UnauthorizedAccessException();
ChatOptions options = new()
{
Instructions = "You help the signed-in user manage their orders. Use the tools; never guess order data.",
Tools =
[
AIFunctionFactory.Create((string? status) => orders.ListAsync(userId, status),
"listOrders", "List the signed-in user's recent orders, optionally filtered by status (open, shipped, cancelled)."),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create((string orderId) => orders.CancelAsync(userId, orderId),
"cancelOrder", "Cancel one of the signed-in user's open orders.")),
],
AllowMultipleToolCalls = false,
};
List<ChatResponseUpdate> updates = [];
await foreach (var update in chat.GetStreamingResponseAsync(history, options, ct))
{
updates.Add(update);
yield return update;
}
history.AddMessages(updates);
}
}2. Stream into a component
The page uses the [Authorize] attribute and the InteractiveServer render mode. Its event handlers consume the service's stream with await foreach, append each text update to the reply in component state and call StateHasChanged(), so Blazor sends a small render diff over the circuit for every update. Nothing in the browser parses a stream. An unhandled exception in an event handler ends the user's circuit, so the handler catches errors, and Dispose cancels a running stream when the user leaves the page.
@page "/assistant"
@attribute [Authorize]
@rendermode InteractiveServer
@implements IDisposable
@using Microsoft.AspNetCore.Authorization
@using Microsoft.Extensions.AI
@inject AgentChat Agent
@foreach (var entry in entries.Where(e => e.Text.Length > 0))
{
<p><strong>@entry.Role:</strong> @entry.Text</p>
}
@if (pending is not null && !busy)
{
<ApprovalPrompt Request="pending" OnDecision="Decide" />
}
<form @onsubmit="Send">
<input @bind="input" aria-label="Message" disabled="@busy" />
<button disabled="@(busy || pending is not null)">Send</button>
</form>
@code {
sealed class Entry(string role) { public string Role => role; public string Text { get; set; } = ""; }
readonly List<Entry> entries = [];
readonly CancellationTokenSource cts = new();
ToolApprovalRequestContent? pending;
string input = "";
bool busy;
Task Send()
{
if (busy || pending is not null || string.IsNullOrWhiteSpace(input)) return Task.CompletedTask;
entries.Add(new("You") { Text = input });
var text = input;
input = "";
return Stream(Agent.SendAsync(text, cts.Token));
}
Task Decide(bool approved)
{
if (busy || pending is not { } request) return Task.CompletedTask;
pending = null;
return Stream(Agent.RespondAsync(request, approved, cts.Token));
}
async Task Stream(IAsyncEnumerable<ChatResponseUpdate> updates)
{
busy = true;
var reply = new Entry("Agent");
entries.Add(reply);
try
{
await foreach (var update in updates)
{
reply.Text += update.Text;
pending ??= update.Contents.OfType<ToolApprovalRequestContent>().FirstOrDefault();
StateHasChanged();
}
}
catch (OperationCanceledException)
{
// The user left the page and Dispose cancelled the stream.
}
catch (Exception)
{
// An unhandled exception in an event handler would terminate the user's circuit.
reply.Text = "Something went wrong. Try again.";
}
finally
{
busy = false;
}
}
public void Dispose()
{
cts.Cancel();
cts.Dispose();
}
}3. The approval component
The approval request object stays in server memory. The component shows the tool name and arguments, and the Approve and Deny buttons only tell the server which one was clicked through an EventCallback, so a client cannot invent a tool call or change its arguments. The page renders the prompt only while a request is pending, and its handler ignores a decision that arrives when none is.
@using System.Text.Json
@using Microsoft.Extensions.AI
<div role="group" aria-label="Approve agent action">
<p>The agent wants to run <code>@Call.Name</code> with <code>@JsonSerializer.Serialize(Call.Arguments)</code>.</p>
<button @onclick="() => OnDecision.InvokeAsync(true)">Approve</button>
<button @onclick="() => OnDecision.InvokeAsync(false)">Deny</button>
</div>
@code {
[Parameter, EditorRequired] public ToolApprovalRequestContent Request { get; set; } = default!;
[Parameter] public EventCallback<bool> OnDecision { get; set; }
FunctionCallContent Call => (FunctionCallContent)Request.ToolCall;
}Production checklist for Blazor
- Authorize the page and every tool. With the template's static router, the attribute
[Authorize]on a routable component is enforced when the page is requested; with a global interactive router, use the componentAuthorizeRouteView. Scope every query in your services to the user from the authentication state, never to an ID a component receives from the browser. - Know what the circuit's identity means. The authentication state is established when the circuit connects and is not re-read from the cookie on every event. If a sign-out elsewhere must end agent access, use a revalidating authentication state provider, as the Identity template does (Blazor authentication).
- Treat events and tool data as untrusted. The Blazor guide threat mitigation for interactive server-side rendering says a malicious client can send any event payload, so treat event handlers like public endpoints. The model chooses tool arguments, and tool results (user-written text, emails, web pages) can contain instructions. Validate arguments with your own business rules, and require approval for anything irreversible.
- Rate-limit in the service. Messages travel over the circuit's existing connection, not as HTTP requests, so the ASP.NET Core rate-limiting middleware never sees them. Count messages per user in the agent service, keep
MaximumIterationsPerRequestlow and set spending limits with your model provider. - Budget server memory and time. Each conversation lives in server memory for the life of the circuit, and Blazor applies no request timeout to work running in a circuit. Cap the conversation length, cancel streams on dispose or after a timeout, and persist conversations if they must survive a lost circuit.
- Plan for scale-out. Interactive Server should run over WebSockets and needs session affinity (sticky sessions) behind a load balancer, and a long stream keeps that circuit busy until it ends.
- Keep the agent on the server. WebAssembly and Interactive Auto components run in the browser, where a model key would be exposed. For those, call an authorized server endpoint instead, as described in the ASP.NET Core guide, and keep keys in user secrets, environment variables or a vault.
Option: embed an in-app agent with Bunkhouse
If the agent's job is to help signed-in users do work your Blazor app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.
The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your Blazor backend keeps enforcing its own authorization on every request.
In a Blazor Web App, the loader goes in the static <head> of the root component file Components/App.razor, never in a page, layout or interactive component, so each document has exactly one. Load blazor.web.js with autostart="false", then the identity hook and the loader, then your own scripts, and call Blazor.start() last, so the embed is installed before Blazor makes its first requests. For signed-in users, map a GET /api/agent-identity Minimal API endpoint that signs a short-lived token for the user in HttpContext.User; the signing secret stays in server configuration.
<head>
<meta charset="utf-8" />
<base href="/" />
<HeadOutlet />
@* Blazor loads without starting, so the Bunkhouse loader is installed before Blazor makes requests. *@
<script src="@Assets["_framework/blazor.web.js"]" autostart="false"><\/script>
<script src="/agent-identity.js"><\/script>
<script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
@* Your own scripts, then Blazor.start() last. *@
<script src="/blazor-start.js" defer><\/script>
</head>The tested Blazor integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.
When Bunkhouse fits, and when to build it yourself
- Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
- Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
- Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
- Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.
Try it on your Blazor app first
A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your Blazor app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Blazor page at http://localhost:5000.Use a page with disposable data: everything the assistant reads is sent to your agent.
The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.
Related guides
- How to add AI to your website: chat assistants, in-app agents and custom builds compared
- MCP vs WebMCP: choosing where outside agents act on your site
- AI agents in other C# and .NET frameworks: ASP.NET Core