Skip to content

Blazor

This guide adds Bunkhouse to a Blazor Web App that uses global Interactive Server rendering: pages are prerendered on the server, then routed and updated over the Blazor circuit. Every file shown below is part of a runnable example app that is tested end to end against the real Bunkhouse API and embed.

Framework Blazor Web App, ASP.NET Core 10.0.14, global Interactive Server render mode (interactive router), enhanced navigation on (Blazor’s default), cookie authentication
Runtime .NET 10 (.NET 10.0.14 in the tested image, SDK 10.0.403)
Example apps/docs/integrations/blazor/ in the Bunkhouse repository

Blazor WebAssembly and the Interactive Auto mode are not covered here; the signing secret and mint key must stay on the server either way.

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 ASP.NET Core cookie authentication with two demo users; your own authentication (ASP.NET Core Identity, OpenID Connect, …) takes its place. Bunkhouse only needs one thing from it: the signed-in user’s stable ID in HttpContext.User.
File Purpose
AgentConfig.cs Validates the public Bunkhouse configuration at startup
AgentEndpoints.cs GET /api/agent-identity and POST /api/agent-session as Minimal API endpoints
ContentSecurityPolicy.cs Your Content-Security-Policy with the Bunkhouse sources added
Components/App.razor The root document: Blazor’s script, the page hooks and the loader, then Blazor.start()
Components/AgentEmbed*.razor The script tags for one installation mode
wwwroot/agent-identity.js, wwwroot/agent-auth-headers.js Page hooks, loaded before the loader
wwwroot/blazor-start.js Starts Blazor after the loader

Set the server environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser:

Terminal window
AGENT_PUBLIC_KEY=pk_...
AGENT_SIGNING_SECRET=ss_... # server only
AGENT_SIGNING_KID=... # key ID shown with the signing secret
AGENT_ALLOWED_ORIGINS='["https://app.example.com"]'
# AGENT_API_BASE and AGENT_EMBED_ORIGIN default to https://api.bunkhouse.ai and https://embed.bunkhouse.ai
AgentConfig.cs
// 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 endpoints sign a ten-minute HS256 token with HMACSHA256 for the user your authentication already signed in. They reject other methods (405), requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a login redirect), and mark every response Cache-Control: no-store. The subject comes only from HttpContext.User, never from the query, body or headers. The same file contains the server mint endpoint used by the alternative below.

AgentEndpoints.cs
// 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 (HttpContext.User).
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) });
});
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 them after authentication. CurrentUserId in AgentEndpoints.cs is the seam to your authentication: it reads the name-identifier claim of HttpContext.User.

Program.cs (excerpt)
// Bunkhouse endpoints. They read the signed-in user from HttpContext.User, which your existing
// authentication (cookies, ASP.NET Core Identity, OpenID Connect) has already populated.
app.MapAgentEndpoints();

Blazor’s script must not start before the loader is installed, or Blazor’s first requests and the page’s startup requests happen before the embed can observe them. Load blazor.web.js with autostart="false", then the page hooks and the loader, then your own scripts, and call Blazor.start() last (manual start). All of it lives in the static head of the root App.razor, never in a routed page, layout or interactive component, so each document has exactly one loader. App.razor is also where the per-response CSP nonce and the antiforgery token come from (its @code block), and where the router is made interactive:

Components/App.razor
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="/" />
<HeadOutlet @rendermode="InteractiveServer" />
<meta name="csrf-token" content="@Antiforgery.GetAntiforgeryToken()?.Value" />
@* Blazor loads without starting, so the Bunkhouse loader is installed before Blazor makes requests. *@
<script src="@Assets["_framework/blazor.web.js"]" autostart="false" nonce="@Nonce"></script>
@* Page hooks merge into window.AgentEmbedConfig before the loader runs. *@
<script src="/agent-auth-headers.js" nonce="@Nonce"></script>
@switch (Mode)
{
case "mint":
<AgentEmbedMint Nonce="@Nonce" />
break;
case "anonymous":
<AgentEmbedAnonymous Nonce="@Nonce" />
break;
default:
<AgentEmbedIdentity Nonce="@Nonce" />
break;
}
@* The application's own JavaScript, then Blazor.start(), come after the loader. *@
<script src="/app.js" nonce="@Nonce" defer></script>
<script src="/blazor-start.js" nonce="@Nonce" defer></script>
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<AccountPanel />
</body>
</html>
@code {
[CascadingParameter] HttpContext HttpContext { get; set; } = default!;
[Inject] AntiforgeryStateProvider Antiforgery { get; set; } = default!;
// Test-shell switch between the three installation modes (?mode=); a real app uses exactly one.
[SupplyParameterFromQuery] string? Mode { get; set; }
string Nonce => ContentSecurityPolicy.Nonce(HttpContext);
}
Components/AgentEmbedIdentity.razor
@* Identity token mode (recommended): the hook first, then the loader, before Blazor starts. *@
@inject AgentConfig Agent
<script src="/agent-identity.js" nonce="@Nonce"></script>
<script src="@($"{Agent.EmbedOrigin}/embed.js")" nonce="@Nonce"
data-public-key="@Agent.PublicKey" data-hints-url="/agent-hints.json"></script>
@code {
[Parameter, EditorRequired] public string Nonce { get; set; } = "";
}
wwwroot/agent-identity.js
// 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;
},
};
wwwroot/blazor-start.js
// blazor.web.js is loaded with autostart="false"; Blazor starts after the Bunkhouse loader is installed.
Blazor.start();

The hooks are external same-origin scripts, so no inline script needs a CSP exception. blazor-start.js is deferred, so it runs once the document is parsed. The example’s App.razor renders one of the three AgentEmbed* components, selected by a test-only ?mode= query parameter. Your application uses exactly one.

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.

Components/AgentEmbedMint.razor
@* Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. *@
@inject AgentConfig Agent
<script src="@($"{Agent.EmbedOrigin}/embed.js")" nonce="@Nonce"
data-public-key="@Agent.PublicKey" data-hints-url="/agent-hints.json"
data-session-endpoint="/api/agent-session"></script>
@code {
[Parameter, EditorRequired] public string Nonce { get; set; } = "";
}

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 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.

Minimal API endpoints validate antiforgery tokens automatically only when they bind form data, and the mint endpoint binds none. .DisableAntiforgery() makes the exemption explicit: the embed’s POST carries no antiforgery token, and the three header checks are this route’s CSRF protection. Antiforgery stays on for everything else; if you add a global antiforgery requirement, exempt only this route.

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.

Components/AgentEmbedAnonymous.razor
@* Anonymous mode (enable it for the site first): the loader alone. *@
@inject AgentConfig Agent
<script src="@($"{Agent.EmbedOrigin}/embed.js")" nonce="@Nonce"
data-public-key="@Agent.PublicKey" data-hints-url="/agent-hints.json"></script>
@code {
[Parameter, EditorRequired] public string Nonce { get; set; } = "";
}

The example signs in and out like the Blazor Identity template: ordinary (not enhanced) form posts with <AntiforgeryToken /> to server endpoints that set or clear the authentication cookie and redirect back. A cookie cannot be set over the Blazor circuit, so every account change loads a new document, and a new document starts a new embed with no session; the previous user’s session never carries over. No same-document account change exists, so no resetSession() call is needed. If you add one (for example a sign-in inside an interactive component), call window.AgentEmbed?.resetSession() right after it succeeds and before the new user’s requests.

Program.cs (excerpt)
// Secure everywhere except the loopback HTTP test origin.
var cookieSecurity = appOrigin.StartsWith("https://") ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
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;
});
builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "RequestVerificationToken";
options.Cookie.SecurePolicy = cookieSecurity;
});

The account forms are rendered statically, outside the interactive router, so prerendered form fields are not replaced when the circuit connects:

Components/Layout/AccountPanel.razor
@* Statically rendered, outside the interactive router: the cookie can only change in a form post that
loads a new document, and app.js fills #startup-items once per document. *@
@inject NavigationManager Navigation
<section>
<AuthorizeView>
<Authorized><p id="current-user">Signed in as @context.User.Identity?.Name</p></Authorized>
<NotAuthorized><p id="current-user">Signed out</p></NotAuthorized>
</AuthorizeView>
<p id="startup-items">Loading startup items…</p>
<form id="login-form" method="post" action="/login">
<AntiforgeryToken />
<input type="hidden" name="next" value="@NextUrl" />
<label>Username <input name="username" autocomplete="username" /></label>
<label>Password <input name="password" type="password" autocomplete="current-password" /></label>
<button type="submit">Sign in</button>
</form>
<AuthorizeView>
<form method="post" action="/logout">
<AntiforgeryToken />
<input type="hidden" name="next" value="@NextUrl" />
<button type="submit">Sign out</button>
</form>
</AuthorizeView>
</section>
@code {
string NextUrl => "/" + Navigation.ToBaseRelativePath(Navigation.Uri);
}

Pages and their layout are interactive. NavLink clicks are handled by the circuit’s router: the page content changes, while the document, the loader and an open pane stay in place and the embed keeps its session. A link clicked before the circuit connects uses enhanced navigation, which rewrites the document in place; the embed restores its launcher, pane and --agent-embed-width afterwards and keeps its session. If you make the account forms enhanced (data-enhance or Enhance), an account change no longer loads a new document, so call resetSession() after it succeeds.

Components/Layout/MainLayout.razor
@inherits LayoutComponentBase
<nav><NavLink href="/" Match="NavLinkMatch.All">Home</NavLink> · <NavLink href="/second">Second page</NavLink></nav>
<main>
@Body
</main>

The application’s startup script fetches its data once per document. Because the static forms keep the URL they were rendered with, it also points their next field at the page the user navigated to:

wwwroot/app.js
// The application's startup code: its first API request, which the loader (already installed) observes.
const out = document.getElementById("startup-items");
fetch("/api/startup-items", { credentials: "same-origin" })
.then((res) => res.json())
.then(({ items }) => {
out.textContent = `Startup items: ${items.map((item) => item.name).join(", ")}`;
});
// The account forms are static; after in-app navigation, return to the page the user is on now.
document.addEventListener("submit", (event) => {
const next = event.target.elements?.namedItem("next");
if (next) next.value = location.pathname + location.search;
});

The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. The example renders ASP.NET Core’s antiforgery request token into a <meta> tag (see App.razor above), and its JSON notes API validates the RequestVerificationToken header with IAntiforgery.IsRequestValidAsync:

wwwroot/agent-auth-headers.js
// The app's own antiforgery token (rendered by the server into a <meta> tag) 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.

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 sets a fresh nonce per response and puts it on every script tag, blazor.web.js included. Interactive Server needs no 'unsafe-inline' or 'unsafe-eval': the circuit’s WebSocket is same-origin and covered by connect-src 'self' (Blazor CSP). Blazor additionally sends its own frame-ancestors 'self' policy header for interactive server rendering.

ContentSecurityPolicy.cs
// 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, blazor.web.js included.
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);
});
}
}

From a checkout of the Bunkhouse repository, with Docker and local Supabase running:

Terminal window
node tests/framework-integrations/run.ts blazor

The run builds the example image, 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, navigation with a single loader, 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 app.MapAgentEndpoints() must run after UseAuthentication(), and HttpContext.User must carry a name-identifier claim.
A login page instead of 401 Do not add RequireAuthorization() to the Bunkhouse endpoints; they answer 401 themselves.
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.
400 from the mint endpoint Antiforgery validation reached it; keep .DisableAntiforgery() on /api/agent-session only.
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; put the response’s nonce on every script tag.
Startup requests are not discovered blazor.web.js needs autostart="false", and Blazor.start() and your scripts must come after the loader.
Two launchers Render the loader once, in App.razor, not in a page, layout or interactive component.
_framework/blazor.web.js returns 404 The project was restored without its .razor files (for example a Docker layer that copies only the project file); set <RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>.
A secret in page source Only AGENT_PUBLIC_KEY and origins belong in components; keep ss_ and sk_ values in server configuration.