AI agents by framework / C# and .NET

How to add an AI agent to an ASP.NET Core app

An AI agent in an ASP.NET Core app is an authorized endpoint that calls a model with tools, runs the tool calls the model makes against your own services as the signed-in user, and streams the reply to the page. Microsoft.Extensions.AI provides the model client, the function-calling loop and tool approval; you supply the tools, authorization, conversation storage and limits. If the agent only needs to do what your pages already do, you can embed one instead.

What an agent in an ASP.NET Core app is made of

  • A server endpoint: a Minimal API route group (or a controller action) that calls the model with a key from configuration. The key comes from user secrets in development and from environment variables or a vault in production, never from appsettings.json in source control or from a view.
  • Tools: AIFunction instances 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 UI: a Razor Page or MVC view with a small script that reads the streamed reply.
  • Authorization and approval: an authorization policy and an antiforgery check on the endpoints, tools that act only as the signed-in user, and writes that wait for the user's approval.

Ways to add an agent to ASP.NET Core

ApproachGood forWhat you take on
Microsoft.Extensions.AI in an endpointA custom agent with your own tools and UI, on any provider that has an IChatClient implementation.Tool definitions, authorization in every tool, conversation storage, approval UI, rate and cost limits.
Microsoft Agent FrameworkAgents 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 row above.
Hosted answer-only chat widgetQuestions 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.

Microsoft Agent Framework is the successor to Semantic Kernel and AutoGen and is built on the same IChatClient abstraction, so tools written for the approach below carry over if you move to it later.

Build an agent with Microsoft.Extensions.AI

Microsoft.Extensions.AI is the .NET abstraction for model clients. Its IChatClient interface streams responses as IAsyncEnumerable, and its function-invoking client runs tools and handles tool approval. The examples use version 10.11 with ASP.NET Core 10 and the OpenAI provider package:

Terminal
dotnet package add Microsoft.Extensions.AI
dotnet package add Microsoft.Extensions.AI.OpenAI

Store 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 policies

The method AddChatClient registers the model client in dependency injection, and the method UseFunctionInvocation wraps it in the agent loop. The setting MaximumIterationsPerRequest caps one request at five model round trips. The agent authorization policy requires a signed-in user with a stable ID claim, the antiforgery header name lets the script send the request token, and the rate limiter allows each user 20 agent requests a minute.

Program.cs
using System.Security.Claims;
using System.Threading.RateLimiting;
using Microsoft.Extensions.AI;
using OpenAI;

var builder = WebApplication.CreateBuilder(args);
// ...your existing authentication, Razor Pages 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.AddAuthorizationBuilder()
    .AddPolicy("agent", policy => policy.RequireAuthenticatedUser().RequireClaim(ClaimTypes.NameIdentifier));
builder.Services.AddAntiforgery(options => options.HeaderName = "RequestVerificationToken");
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("agent", http => RateLimitPartition.GetFixedWindowLimiter(
        http.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(1) }));
});

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseAntiforgery();
app.MapAgent();
app.MapRazorPages();
app.Run();

2. The agent endpoints

Both endpoints sit in one route group with the authorization policy, the rate limit and an endpoint filter that validates the antiforgery token: Minimal API endpoints that bind JSON are not validated automatically. The tools close over the user ID from the authenticated ClaimsPrincipal, so the model can choose an order ID but never whose orders it reads or cancels. 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 conversation stays on the server, so the browser only sends back the request ID and a decision. The run records the request as the user's pending approval before it streams the event, and the approval endpoint takes that pending request atomically, so a client cannot invent a tool call, and a second or concurrent answer to the same request gets 409. A per-user semaphore runs one agent turn at a time: an approval sent while the previous reply is still streaming waits until that reply is in the history. 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).

The method Results.ServerSentEvents, new in ASP.NET Core 10, writes each event as a Server-Sent Event with a JSON payload as the async stream produces it (Minimal API responses). The request's CancellationToken stops the model call when the user closes the tab.

AgentEndpoints.cs
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.Extensions.AI;

public record AgentEvent(string Type, string? Text = null, string? ApprovalId = null, string? Tool = null, string? Arguments = null);
public record MessageInput(string Text);
public record ApprovalInput(bool Approved);

public static class AgentEndpoints
{
    sealed class Conversation
    {
        public readonly List<ChatMessage> History = [];
        public readonly SemaphoreSlim Lock = new(1, 1);
        public ToolApprovalRequestContent? Pending;
    }

    // In memory on one instance; keep conversations in a database or distributed cache for several instances or restarts.
    static readonly ConcurrentDictionary<string, Conversation> Conversations = new();

    public static void MapAgent(this WebApplication app)
    {
        var agent = app.MapGroup("/agent")
            .RequireAuthorization("agent")
            .RequireRateLimiting("agent")
            // JSON endpoints are not antiforgery-validated automatically; check the header token here.
            .AddEndpointFilter(async (context, next) =>
            {
                var antiforgery = context.HttpContext.RequestServices.GetRequiredService<IAntiforgery>();
                return await antiforgery.IsRequestValidAsync(context.HttpContext) ? await next(context) : Results.BadRequest();
            });

        agent.MapPost("/messages", (MessageInput input, ClaimsPrincipal user, IChatClient chat, IOrderService orders, CancellationToken ct) =>
        {
            var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
            var conversation = Conversations.GetOrAdd(userId, _ => new());
            return Results.ServerSentEvents(Run(chat, conversation, new(ChatRole.User, input.Text), Options(userId, orders), ct));
        });

        agent.MapPost("/approvals/{id}", (string id, ApprovalInput input, ClaimsPrincipal user, IChatClient chat, IOrderService orders, CancellationToken ct) =>
        {
            var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
            // Take the pending request atomically, so a second or concurrent answer to it gets 409.
            if (!Conversations.TryGetValue(userId, out var conversation) || conversation.Pending is not { } request
                || request.RequestId != id || Interlocked.CompareExchange(ref conversation.Pending, null, request) != request)
                return Results.Conflict();
            var answer = new ChatMessage(ChatRole.User, [request.CreateResponse(input.Approved)]);
            return Results.ServerSentEvents(Run(chat, conversation, answer, Options(userId, orders), ct));
        });
    }

    // The tools close over the authenticated user: the model picks arguments, never whose data it touches.
    static ChatOptions Options(string userId, IOrderService orders) => 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,
    };

    static async IAsyncEnumerable<AgentEvent> Run(IChatClient chat, Conversation conversation, ChatMessage message,
        ChatOptions options, [EnumeratorCancellation] CancellationToken ct)
    {
        // One run per user at a time: an approval sent while the previous reply is still streaming waits here
        // until that reply, including the approval request, is in the history.
        await conversation.Lock.WaitAsync(ct);
        var completed = false;
        try
        {
            conversation.History.Add(message);
            List<ChatResponseUpdate> updates = [];
            await foreach (var update in chat.GetStreamingResponseAsync(conversation.History, options, ct))
            {
                updates.Add(update);
                if (update.Text is { Length: > 0 } text) yield return new("text", text);
                foreach (var request in update.Contents.OfType<ToolApprovalRequestContent>())
                    if (request.ToolCall is FunctionCallContent call)
                    {
                        conversation.Pending = request; // registered before the browser can answer it
                        yield return new("approval", ApprovalId: request.RequestId, Tool: call.Name, Arguments: JsonSerializer.Serialize(call.Arguments));
                    }
            }
            conversation.History.AddMessages(updates);
            completed = true;
        }
        finally
        {
            if (!completed) conversation.Pending = null; // its request never reached the history
            conversation.Lock.Release();
        }
    }
}

In an MVC controller, the attributes [Authorize(Policy = "agent")], [ValidateAntiForgeryToken] and the attribute [EnableRateLimiting("agent")] do the same jobs, and an action can return the same result from Results.ServerSentEvents.

3. The page and its script

The Razor Page uses the same policy and renders the antiforgery token as a hidden field. The browser API EventSource only sends GET requests, so the script posts with fetch and reads the event stream itself. An approval event shows Approve and Deny buttons; either one posts the decision and streams the rest of the reply.

Pages/Agent.cshtml
@page
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Policy = "agent")]

<div id="agent-log" aria-live="polite"></div>
<form id="agent-form">
    @Html.AntiForgeryToken()
    <input name="text" aria-label="Message" autocomplete="off" required />
    <button>Send</button>
</form>
<script src="/agent.js" type="module"><\/script>
wwwroot/agent.js
const log = document.getElementById("agent-log");
const form = document.getElementById("agent-form");
const token = form.elements.__RequestVerificationToken.value;

async function stream(url, body) {
  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json", RequestVerificationToken: token },
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error(`Agent request failed: ${response.status}`);
  const reply = log.appendChild(document.createElement("p"));
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
    buffer += chunk.value;
    const frames = buffer.split("\n\n");
    buffer = frames.pop();
    for (const frame of frames) {
      const data = frame.split("\n").filter((line) => line.startsWith("data: ")).map((line) => line.slice(6)).join("\n");
      if (!data) continue;
      const event = JSON.parse(data);
      if (event.type === "text") reply.textContent += event.text;
      if (event.type === "approval") askApproval(event);
    }
  }
}

function askApproval({ approvalId, tool, arguments: args }) {
  const box = log.appendChild(document.createElement("div"));
  box.append(`Run ${tool} with ${args}? `);
  for (const approved of [true, false]) {
    const button = box.appendChild(document.createElement("button"));
    button.textContent = approved ? "Approve" : "Deny";
    button.onclick = () => {
      box.remove();
      stream(`/agent/approvals/${encodeURIComponent(approvalId)}`, { approved });
    };
  }
}

form.addEventListener("submit", (e) => {
  e.preventDefault();
  const text = form.elements.text.value;
  form.reset();
  log.appendChild(document.createElement("p")).textContent = text;
  stream("/agent/messages", { text });
});

Production checklist for ASP.NET Core

  • Authorize the endpoints and every tool. Put the policy on the route group, read the user from the request's HttpContext.User and never from the request body, and scope every query in your services to that user. In ASP.NET Core 10, cookie authentication answers known API endpoints like these with 401 and 403 instead of a login redirect.
  • Keep CSRF protection on. UseAntiforgery() only validates endpoints that bind form data, so JSON endpoints that act with the user's cookie need their own check, as in the filter above (antiforgery in ASP.NET Core).
  • Treat tool input and tool results as untrusted. The model chooses tool arguments, and data a tool returns (user-written text, emails, web pages) can contain instructions. Validate arguments with your own business rules, and require approval for anything irreversible.
  • Limit cost. Keep MaximumIterationsPerRequest low, rate-limit per user with the RequireRateLimiting policy, cap the stored conversation length and set spending limits with your model provider.
  • Allow for long streams. Keep the handler async end to end (no .Result or blocking .Wait() calls), so a long stream does not hold a thread pool thread, and make sure a reverse proxy in front of Kestrel does not buffer text/event-stream responses or close them at a short idle timeout.
  • Store conversations deliberately. The in-memory conversations and their per-user lock work on one instance only. With several instances or restarts, keep conversations and pending approvals in a database, and take the pending approval with an atomic update, or route each user to one instance.
  • Keep secrets server-side. Use user secrets in development and environment variables (for example OpenAI__ApiKey) or a vault configuration provider in production.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your ASP.NET Core 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 ASP.NET Core backend keeps enforcing its own authorization on every request.

In ASP.NET Core, add the identity hook and the loader to the <head> of the layout file Views/Shared/_Layout.cshtml, before your application's own scripts, so they load once per document. For signed-in users, map a GET /api/agent-identity Minimal API endpoint that signs a short-lived token for the name-identifier claim of HttpContext.User, after the call to UseAuthentication(); the signing secret stays in server configuration.

Views/Shared/_Layout.cshtml (excerpt)
<head>
    <meta charset="utf-8" />
    <title>@ViewData["Title"]</title>
    <script src="/agent-identity.js"><\/script>
    <script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
    @* Your application's own scripts come after the loader. *@
    <script src="/app.js" defer><\/script>
</head>

The tested ASP.NET Core 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 ASP.NET Core 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 ASP.NET Core 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:

Agent trialPrompt for your coding agent
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy ASP.NET Core 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.

Add an agent to your ASP.NET Core app.