ASP.NET Core
This guide adds Bunkhouse to a server-rendered ASP.NET Core application: an MVC host with Razor views, and the two Bunkhouse endpoints as Minimal APIs. Every file shown below is part of a runnable example app that is tested end to end against the real Bunkhouse API and embed.
Tested setup
Section titled “Tested setup”| Framework | ASP.NET Core 10 (shared framework 10.0.14): MVC with Razor views, Minimal API endpoints, cookie authentication, antiforgery |
| Runtime | .NET 10 (.NET 10.0.14 in the tested image, SDK 10.0.403) |
| Example | apps/docs/integrations/aspnet-core/ in the Bunkhouse repository |
You need:
- Your application’s exact origin registered on the site (for example
https://app.example.com). - The site’s public key (
pk_...). - For identity tokens (recommended): a signing secret (
ss_...) and its key ID from the site’s Keys page. - For server mint instead: a mint key (
sk_...). - Existing sign-in. The example uses cookie authentication with two demo users; your own authentication (cookies, ASP.NET Core Identity, OpenID Connect) takes its place. Bunkhouse only needs one thing from it: the signed-in user’s stable ID on
HttpContext.User.
| File | Purpose |
|---|---|
AgentConfig.cs |
Validates the public Bunkhouse configuration at startup |
AgentEndpoints.cs |
GET /api/agent-identity and POST /api/agent-session |
ContentSecurityPolicy.cs |
Your Content-Security-Policy with the Bunkhouse sources added |
Views/Shared/_AgentEmbed*.cshtml |
The script tags for one installation mode |
wwwroot/agent-identity.js, wwwroot/agent-auth-headers.js |
Page hooks, loaded before the loader |
Identity token (recommended)
Section titled “Identity token (recommended)”Set the server environment (or the equivalent configuration keys). Only AGENT_PUBLIC_KEY and the origins ever reach the browser:
AGENT_PUBLIC_KEY=pk_...AGENT_SIGNING_SECRET=ss_... # server onlyAGENT_SIGNING_KID=... # key ID shown with the signing secretAGENT_ALLOWED_ORIGINS='["https://app.example.com"]'# AGENT_API_BASE and AGENT_EMBED_ORIGIN default to https://api.bunkhouse.ai and https://embed.bunkhouse.aiAgentConfig reads them through IConfiguration and is registered as a singleton, so the app refuses to start with an invalid public key or origin:
// Bunkhouse public configuration, validated once at startup. Signing and mint credentials are read// only by the endpoints that use them, so each mode needs only its own secret.using System.Text.Json;
public sealed record AgentConfig(string PublicKey, string[] AllowedOrigins, string ApiBase, string EmbedOrigin){ public static AgentConfig FromConfiguration(IConfiguration config) { var publicKey = config["AGENT_PUBLIC_KEY"] ?? ""; if (!publicKey.StartsWith("pk_", StringComparison.Ordinal)) throw new InvalidOperationException("AGENT_PUBLIC_KEY must be the site's public key (pk_...)");
var origins = JsonSerializer.Deserialize<string[]>(config["AGENT_ALLOWED_ORIGINS"] ?? "[]") ?? []; if (origins.Length == 0) throw new InvalidOperationException("""AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]""");
return new( publicKey, [.. origins.Select(origin => ExactOrigin("AGENT_ALLOWED_ORIGINS", origin))], ExactOrigin("AGENT_API_BASE", config["AGENT_API_BASE"] ?? "https://api.bunkhouse.ai"), ExactOrigin("AGENT_EMBED_ORIGIN", config["AGENT_EMBED_ORIGIN"] ?? "https://embed.bunkhouse.ai")); }
static string ExactOrigin(string name, string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http" && uri.GetLeftPart(UriPartial.Authority) == value ? value : throw new InvalidOperationException($"{name} must be an exact origin like https://app.example.com");}The identity endpoint signs a ten-minute HS256 token with HMACSHA256 for the user your authentication already signed in. It rejects other methods (405), requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a login redirect), and marks every response Cache-Control: no-store. The subject is the ClaimTypes.NameIdentifier claim of HttpContext.User, never a value from the query, body or headers. The same file contains the server mint endpoint used by the alternative below.
// Bunkhouse endpoints: GET /api/agent-identity (identity token mode) and POST /api/agent-session// (server mint mode). Both vouch only for the user your existing authentication signed in.using System.Buffers.Text;using System.Security.Claims;using System.Security.Cryptography;using System.Text;using System.Text.Json;
public static class AgentEndpoints{ /// <summary>Your existing authentication: the signed-in user's stable ID, or null.</summary> static string? CurrentUserId(HttpContext http) => http.User.Identity?.IsAuthenticated == true ? http.User.FindFirstValue(ClaimTypes.NameIdentifier) : null;
/// <summary>HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes.</summary> public static string SignIdentityToken(string userId, string publicKey, string secret, string kid) { var iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var header = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT", kid })); var payload = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(new { sub = userId, aud = publicKey, iat, exp = iat + 600 })); var signature = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{header}.{payload}")); return $"{header}.{payload}.{Base64Url.EncodeToString(signature)}"; }
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app) { app.MapGet("/api/agent-identity", (HttpContext http, AgentConfig agent, IConfiguration config, ILoggerFactory logs) => { http.Response.Headers.CacheControl = "no-store"; if (http.Request.Headers["Sec-Fetch-Site"] != "same-origin") return Error(403, "forbidden"); if (CurrentUserId(http) is not { } userId) return Error(401, "unauthorized"); if (config["AGENT_SIGNING_SECRET"] is not { Length: > 0 } secret) return NotConfigured(logs, "AGENT_SIGNING_SECRET"); if (config["AGENT_SIGNING_KID"] is not { Length: > 0 } kid) return NotConfigured(logs, "AGENT_SIGNING_KID"); return Results.Json(new { identityToken = SignIdentityToken(userId, agent.PublicKey, secret, kid) }); }); // Every other method, including HEAD and OPTIONS (method-specific endpoints win). app.Map("/api/agent-identity", MethodNotAllowed("GET"));
app.MapPost("/api/agent-session", async (HttpContext http, AgentConfig agent, IConfiguration config, IHttpClientFactory clients, ILoggerFactory logs) => { http.Response.Headers.CacheControl = "no-store"; // The embed's POST carries no antiforgery token; these three checks are this route's CSRF defense. string? origin = http.Request.Headers.Origin; if (origin is null || !agent.AllowedOrigins.Contains(origin) || http.Request.Headers["X-Agent-Embed"] != "1" || http.Request.Headers["Sec-Fetch-Site"] != "same-origin") return Error(403, "forbidden"); if (CurrentUserId(http) is not { } userId) return Error(401, "unauthorized"); if (config["AGENT_EMBED_SECRET_KEY"] is not { Length: > 0 } key) return NotConfigured(logs, "AGENT_EMBED_SECRET_KEY");
var client = clients.CreateClient(); client.Timeout = TimeSpan.FromSeconds(15); using var request = new HttpRequestMessage(HttpMethod.Post, $"{agent.ApiBase}/v1/embed/sessions") { Headers = { Authorization = new("Bearer", key) }, // A buffered body with Content-Length, not JsonContent's chunked stream. Content = new StringContent(JsonSerializer.Serialize(new { publicKey = agent.PublicKey, mode = "signed", userId, origin }), Encoding.UTF8, "application/json"), }; byte[] body; HttpResponseMessage upstream; try { upstream = await client.SendAsync(request, http.RequestAborted); body = await upstream.Content.ReadAsByteArrayAsync(http.RequestAborted); } catch (Exception e) when (e is HttpRequestException or TaskCanceledException && !http.RequestAborted.IsCancellationRequested) { return Error(502, "session_service_unavailable"); } using (upstream) { http.Response.StatusCode = (int)upstream.StatusCode; http.Response.ContentType = upstream.Content.Headers.ContentType?.ToString() ?? "application/json"; await http.Response.Body.WriteAsync(body, http.RequestAborted); return Results.Empty; } }).DisableAntiforgery(); app.Map("/api/agent-session", MethodNotAllowed("POST"));
return app; }
static IResult Error(int status, string error) => Results.Json(new { error }, statusCode: status);
static Func<HttpContext, IResult> MethodNotAllowed(string allow) => http => { http.Response.Headers.Allow = allow; http.Response.Headers.CacheControl = "no-store"; return Error(405, "method_not_allowed"); };
static IResult NotConfigured(ILoggerFactory logs, string variable) { logs.CreateLogger("Bunkhouse").LogError("{Variable} is not set", variable); return Error(503, "not_configured"); }}Map the endpoints after UseAuthentication(). CurrentUserId in AgentEndpoints.cs is the seam to your authentication: if your stable user ID lives in another claim (for example sub from OpenID Connect), read that claim there.
// Bunkhouse endpoints. They read the signed-in user from HttpContext.User, which the cookie// authentication above (your existing authentication) populates.app.MapAgentEndpoints();In the layout’s <head>, render the page hooks and then the loader, before your application’s own scripts: the hooks define window.AgentEmbedConfig, which the loader reads. In identity mode the mode partial adds the identity hook just ahead of the loader. The hooks are external same-origin scripts, so no inline script needs a CSP exception; every tag carries the response’s nonce:
@inject AgentConfig Agent@* Identity token mode (recommended): the hook first, then the loader, before the app's scripts. *@<script src="/agent-identity.js" nonce="@ContentSecurityPolicy.Nonce(Context)"></script><script src="@Agent.EmbedOrigin/embed.js" nonce="@ContentSecurityPolicy.Nonce(Context)" data-public-key="@Agent.PublicKey" data-hints-url="/agent-hints.json"></script>// Identity token mode: the embed calls this hook whenever it starts a session for the signed-in user.window.AgentEmbedConfig = { ...window.AgentEmbedConfig, getIdentityToken: async () => { const res = await fetch("/api/agent-identity", { credentials: "same-origin", cache: "no-store" }); if (!res.ok) throw new Error("Not signed in"); return (await res.json()).identityToken; },};@if (user is not null){ <meta name="csrf-token" content="@Antiforgery.GetAndStoreTokens(Context).RequestToken" />}@* Page hooks define window.AgentEmbedConfig before the loader in the mode partial reads it. *@<script src="/agent-auth-headers.js" nonce="@nonce"></script>@await Html.PartialAsync($"_AgentEmbed{mode}")<!-- The application's own JavaScript comes after the loader. --><script src="/app.js" nonce="@nonce" defer></script>The example layout renders one of the three _AgentEmbed* partials, selected by a test-only ?mode= switch. Your application uses exactly one.
Server mint alternative
Section titled “Server mint alternative”Instead of the identity hook, add data-session-endpoint to the loader. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence.
@inject AgentConfig Agent@* Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. *@<script src="@Agent.EmbedOrigin/embed.js" nonce="@ContentSecurityPolicy.Nonce(Context)" data-public-key="@Agent.PublicKey" data-hints-url="/agent-hints.json" data-session-endpoint="/api/agent-session"></script>POST /api/agent-session in AgentEndpoints.cs above accepts only requests with an Origin from AGENT_ALLOWED_ORIGINS, X-Agent-Embed: 1 and Sec-Fetch-Site: same-origin (403 otherwise), authenticates the user (401), then creates the session with the mint key through IHttpClientFactory and returns the API’s status and body unchanged with Cache-Control: no-store. If the API is unreachable it returns 502 without details. Set AGENT_EMBED_SECRET_KEY=sk_... on the server; the identity endpoint does not need it.
ASP.NET Core’s antiforgery protection stays on for the rest of the app. The embed’s POST carries no antiforgery token, and its three header checks are this route’s protection. Minimal API antiforgery validation (UseAntiforgery()) only applies to endpoints that bind a form, so it never validates this endpoint, which binds none; .DisableAntiforgery() makes that explicit. If you implement the endpoint as an MVC action under AutoValidateAntiforgeryToken, exempt only that action with [IgnoreAntiforgeryToken].
Anonymous alternative
Section titled “Anonymous alternative”Anonymous sessions need only the loader. Enable anonymous access for the site in the dashboard first and set its monthly token and spend limits; usage is charged to your account’s credit and stops at a limit. Anyone who can load your page can start one, so it is not proof of identity.
@inject AgentConfig Agent@* Anonymous mode (enable it for the site first): the loader alone. *@<script src="@Agent.EmbedOrigin/embed.js" nonce="@ContentSecurityPolicy.Nonce(Context)" data-public-key="@Agent.PublicKey" data-hints-url="/agent-hints.json"></script>Sign-in, sign-out and navigation
Section titled “Sign-in, sign-out and navigation”The example signs in and out with ordinary form posts (antiforgery-validated by UseAntiforgery()) that redirect back to the same page. Every account change therefore loads a new document, and a new document starts a new embed with no session, so the previous user’s session can never carry over. Cookie authentication uses HttpOnly, SameSite cookies, and answers API challenges with 401/403 instead of redirecting to a login page:
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.Cookie.Name = "example.auth"; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = cookieSecurity; // API callers get a status code, never a redirect to a login page. options.Events.OnRedirectToLogin = context => { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; }; options.Events.OnRedirectToAccessDenied = context => { context.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; }; });builder.Services.AddAuthorization();// Pages render the request token into a <meta> tag; JavaScript sends it back in this header.builder.Services.AddAntiforgery(options =>{ options.HeaderName = "RequestVerificationToken"; options.Cookie.Name = "example.antiforgery"; options.Cookie.SecurePolicy = cookieSecurity;});The example keeps its Data Protection keys in memory, so a restart signs everyone out. In production, persist and protect the key ring as you already do for your authentication cookies.
If your app changes the signed-in user without a full page load (a fetch login, a single-page frontend, Blazor), call window.AgentEmbed?.resetSession() right after the login, logout or account switch succeeds and before the new user’s requests; the Blazor guide explains its own sign-in flow. Render the loader once in _Layout.cshtml, not in individual views or partials that pages include.
Auth headers, user IDs and CSP
Section titled “Auth headers, user IDs and CSP”The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. The example’s JSON API validates ASP.NET Core’s antiforgery request token from the RequestVerificationToken header (AntiforgeryOptions.HeaderName). The layout renders the token into a <meta> tag for signed-in users with IAntiforgery.GetAndStoreTokens, and the hook sends it:
// The app's antiforgery request token (rendered by the layout for the signed-in user) for the// assistant's calls to this app's API. Values never reach the model or the sandbox.window.AgentEmbedConfig = { ...window.AgentEmbedConfig, getAuthHeaders: () => { const token = document.querySelector('meta[name="csrf-token"]')?.content; return token ? { RequestVerificationToken: token } : {}; },};The hook merges into window.AgentEmbedConfig, so it works with the identity hook in either order. Its values never reach the model or the sandbox. JSON-bound Minimal API endpoints are not validated automatically, so the notes endpoint calls IAntiforgery.IsRequestValidAsync itself. If your site hints declare the operation, list the header in credentialHeaders as requestverificationtoken.
Send a stable, opaque user ID (the example’s fixture-alice), not a name or email address.
Add the Bunkhouse sources to your existing policy rather than replacing it. The example’s middleware sets a fresh nonce per response, which the views put on their script tags; no 'unsafe-inline' or 'unsafe-eval' is needed:
// The app's existing Content-Security-Policy with the Bunkhouse sources added: the embed origin to// script-src and frame-src, the API to connect-src. A fresh nonce per response goes on the page's// script tags.using System.Security.Cryptography;
public static class ContentSecurityPolicy{ const string NonceKey = "csp-nonce";
/// <summary>This response's nonce, for <c>nonce="..."</c> on script tags.</summary> public static string Nonce(HttpContext http) => (string)http.Items[NonceKey]!;
public static IApplicationBuilder UseContentSecurityPolicy(this IApplicationBuilder app) { var agent = app.ApplicationServices.GetRequiredService<AgentConfig>(); return app.Use((http, next) => { var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); http.Items[NonceKey] = nonce; http.Response.Headers.ContentSecurityPolicy = string.Join("; ", "default-src 'self'", $"script-src 'self' 'nonce-{nonce}' {agent.EmbedOrigin}", $"style-src 'self' 'nonce-{nonce}'", $"frame-src {agent.EmbedOrigin}", $"connect-src 'self' {agent.ApiBase}", "img-src 'self' data:", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'self'"); return next(http); }); }}Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts aspnet-coreThe run builds the example image (dotnet restore --locked-mode, dotnet publish), starts it against a fresh local site and drives the real pane in Chromium: identity, mint and anonymous sessions, endpoint guards, sign-out and account switches, discovery of the page’s startup request, an antiforgery-protected write through getAuthHeaders, CSP and secret checks. In your own app, sign in and open the Assistant launcher: the first message creates a signed session.
| Symptom | Check |
|---|---|
/api/agent-identity returns 401 |
UseAuthentication() must run before the endpoints, and CurrentUserId must read the claim your authentication sets. |
| 403 from either endpoint | The request is cross-site or missing Sec-Fetch-Site; for mint, Origin must exactly match AGENT_ALLOWED_ORIGINS and the loader must carry data-session-endpoint. |
The assistant’s call to an [Authorize] API gets a login redirect |
Make OnRedirectToLogin return 401 for API requests, as in Program.cs above. |
| Another 4xx from the mint endpoint | Once the guards and the sign-in check pass, the endpoint forwards the Bunkhouse API’s status and body unchanged; read its error (for example a wrong mint key or an unregistered origin). A 400 before that comes from an MVC antiforgery filter: exempt only that action with [IgnoreAntiforgeryToken]. |
| 503 | The signing secret, key ID or mint key for the mode you use is not set. |
| The pane reports a CSP block | Add the embed origin to script-src and frame-src and the API to connect-src, and put the response nonce on the script tags. |
| Startup requests are not discovered | The loader must come before your application’s scripts in the head. |
| Two launchers | Render the loader once, in _Layout.cshtml. |
| A secret in page source | Only AGENT_PUBLIC_KEY and origins belong in views; keep ss_ and sk_ values in server configuration. |
Related
Section titled “Related”- Installation, Security and the Page API
- ASP.NET Core: Minimal APIs, Cookie authentication, Antiforgery, Layouts