Blazor UI (RockBot.UserProxy.Blazor)

The Blazor UI is a standalone ASP.NET Core Blazor Server application that provides a real-time chat interface to the agent. It communicates with the agent exclusively through the RabbitMQ message bus — it has no direct reference to the agent host and no access to agent internals.


Architecture

Browser (SignalR)
    │
    ▼
Blazor Server (RockBot.UserProxy.Blazor)
    │   ChatStateService  ─── in-memory chat state, event-driven UI updates
    │   BlazorUserFrontend ── IUserFrontend impl, routes replies into ChatStateService
    │
    ▼
UserProxyService (RockBot.UserProxy)
    │   Publishes: user.message, user.feedback, conversation.history.request
    │   Subscribes: user.response.{proxyId}, conversation.history.response.{proxyId}
    │
    ▼
RabbitMQ (rockbot topic exchange)
    │
    ▼
Agent (RockBot.Agent)

The Blazor UI is stateless with respect to the agent — it holds only the current browser session’s message history in memory (ChatStateService). Agent-side persistence (memory, skills, conversation history) lives on the agent’s PVC.


Key components

UserProxyService

Hosted service that owns the RabbitMQ connection on the Blazor side:

  • Subscribe to user.response.{proxyId} on startup — all agent replies arrive here
  • Publish user.message to send user input to the agent
  • Publish user.feedback to send thumbs-up / thumbs-down signals
  • Publish conversation.history.request and await a correlated history response on first render

Each outbound message carries a CorrelationId. Incoming replies are matched by correlation ID to a pending TaskCompletionSource<AgentReply>. Unmatched replies (unsolicited agent messages) are routed to IUserFrontend.DisplayReplyAsync.

IsConnected and OnConnectionChanged are exposed so the UI can show a connection indicator.

Default reply timeout: configurable via UserProxyOptions.DefaultReplyTimeout.

ChatStateService

Singleton in-process state store for the current browser session:

Method Purpose
LoadHistory(turns, sessionId) Populate from agent’s conversation history on first render
AddUserMessage(content, userId, sessionId) Echo the user’s message immediately (optimistic)
AddAgentReply(reply) Add the agent’s final reply
SetThinkingMessage(message) Update the “thinking” spinner text from intermediate replies
SetProcessing(bool) Show/hide the thinking indicator
RecordFeedback(messageId, isPositive) Mark a message with thumbs-up or thumbs-down
AddError(message) Add an error bubble

OnStateChanged fires after every mutation — the Chat.razor component subscribes and calls StateHasChanged to trigger a re-render.

BlazorUserFrontend

IUserFrontend implementation that bridges the UserProxyService callback into ChatStateService. Handles both normal replies (DisplayReplyAsync) and error messages (DisplayErrorAsync).


Chat page (Chat.razor)

Single-page application at /.

Message rendering

Agent replies are rendered as Markdown using Markdig with AdvancedExtensions (tables, task lists, footnotes, etc.). User messages are rendered as plain text. Error messages use a danger-styled bubble.

Input behaviour

Interaction Effect
Enter Submit message
Shift+Enter Insert newline (multiline input)
Up / Down arrow Cycle through input history (last 50 messages, stored in JS)
Window focus Re-focus the input automatically

Thinking indicator

While the agent is processing, a spinner bubble appears. The text updates in real-time from intermediate AgentReply messages (IsFinal = false) — these show the agent’s current tool call or reasoning step without a full re-render.

Scroll behaviour

When a new message arrives the page scrolls to the top of the new message bubble, not the bottom — so long agent responses are read top-to-bottom rather than starting mid-reply.

Feedback

Every agent reply shows a 👍 / 👎 bar. Clicking either:

  1. Marks the message in ChatStateService (disabling the buttons to prevent double-voting)
  2. Publishes a UserFeedback message to RabbitMQ
  3. The agent receives it as a FeedbackSignalType.Correction (👎) or ThumbsUp signal

Feedback flows into the agent’s IFeedbackStore and influences the dream optimization pass.

Conversation history on reconnect

On first render (after SignalR circuit establishment — not during static prerendering), GetHistoryAsync requests the full conversation history from the agent via RabbitMQ. This means a page reload or new browser tab restores the conversation from the agent’s in-memory store rather than starting blank.

Dark mode

Detects the browser’s prefers-color-scheme on load and allows manual toggle. Dark mode state is scoped to the component lifetime (not persisted across refreshes).

Timezone

Reads the browser’s IANA timezone via Intl.DateTimeFormat().resolvedOptions().timeZone and converts message timestamps to the local timezone for display.


Deployment

The Blazor UI runs as a separate Kubernetes deployment (rockbot-blazor) with its own Docker image (rockylhotka/rockbot-blazor). It requires only:

  • RABBITMQ__HOST, RABBITMQ__PORT, RABBITMQ__USERNAME, RABBITMQ__PASSWORD — message bus connection (injected via ConfigMap + Secret)

It does not need access to the agent data PVC or any agent-internal configuration.

Persistent storage

The chart provisions one small ReadWriteOnce PVC, rockbot-blazor-data (128Mi, sized by blazor.storage.size), mounted read-write at /data/blazor. It holds exactly one thing: the ASP.NET Core data-protection key ring, at the path named by DataProtection__KeyRingPath.

That ring is what protects antiforgery tokens — Program.cs calls app.UseAntiforgery(). Left in memory, which is the framework default when no persistent path is configured, it is regenerated from scratch every time the process starts, so every token the previous process issued is rejected after a restart or a rollout.

Set DataProtection:KeyRingPath and the app persists the ring there, pinning the application name to rockbot-blazor so the purpose string does not drift with the container’s working directory. Leave it empty and the ASP.NET Core defaults apply — on a developer machine those already resolve to a persistent per-user profile directory, so only container deployments need to set it. The path is probed for writability at startup and the app fails to start if it cannot be written; the alternative is silently falling back to in-memory keys, which looks like the feature simply not working.

Two deliberate choices worth knowing:

  • It is not a subPath on the shared PVC. That volume is co-mounted into ephemeral script pods running LLM-authored code, and it is swept by the shared-cleanup CronJob, whose catch-all rule deletes anything older than shared.globalTtlDays (30 days). Key files are written once and never touched again while keys live ~90 days, so active keys would be reaped silently.
  • The deployment uses strategy: Recreate. An RWO volume cannot be mounted by a new pod while the old one holds it, so a rolling update would deadlock with the new pod Pending. This also means blazor.replicaCount must stay at 1 — which it already had to, since conversation state lives in the in-memory ChatStateService.

Keys are stored as plaintext XML (no encryptor is configured on Linux). The protection is that only the Blazor pod ever mounts the volume.

In Kubernetes the pod’s securityContext.fsGroup applies pod-wide, so kubelet chgrps the new volume and the non-root app user writes via group — no init container needed. The Docker Compose stack has no equivalent, so it runs a small blazor-init service that creates and chmods the directory before the app starts.

The UI is exposed on the Tailscale network via the Tailscale Kubernetes Operator, in one of two modes.

Layer 3 (default) — a LoadBalancer Service with loadBalancerClass: tailscale. Plain HTTP, no certificate:

blazor:
  tailscale:
    hostname: "rockbot"   # accessible at http://rockbot on your tailnet

Layer 7 (HTTPS) — an Ingress with ingressClassName: tailscale. The operator provisions a Let’s Encrypt certificate and the Service drops to ClusterIP:

blazor:
  tailscale:
    hostname: "rockbot"
    ingress:
      enabled: true       # https://rockbot.<your-tailnet>.ts.net
      proxyGroup: ""      # optional ProxyGroup name for HA ingress

Requires MagicDNS and the HTTPS Certificates toggle in the Tailscale admin console’s DNS settings. Certificates are only ever issued for <hostname>.<tailnet>.ts.net — never for a bare hostname, and never for a custom domain.

Both modes stay private to your tailnet: the name resolves to a CGNAT 100.x address that is not routable from the internet. Publishing to the public internet would require the tailscale.com/funnel annotation, which this chart never emits.

ACL tags

By default the operator stamps every proxy device it creates with its own configured PROXY_TAGS value. That tag is shared by every service the operator exposes in the cluster, so a tailnet ACL cannot tell one endpoint from another — a rule granting access to the RockBot UI would equally grant access to any other operator-managed endpoint.

Give a deployment its own tag when it needs an access rule of its own:

blazor:
  tailscale:
    hostname: "trees"
    tags: ["tag:trees"]   # every entry must start with "tag:"

The annotation lands on whichever resource owns the Tailscale device — the Service in layer-3 mode, the Ingress in layer-7 mode. A tailnet policy can then scope access to just this endpoint:

{ "action": "accept", "src": ["teresa@example.com"], "dst": ["tag:trees:443"] }

Two prerequisites, both in the tailnet policy file, and the proxy Pod will not register without them — it fails quietly, so the endpoint simply never appears:

  1. The tag must exist in tagOwners.
  2. The operator’s OAuth client must own it, since a client may only apply tags it owns: "tagOwners": { "tag:trees": ["tag:k8s-operator"] }.

Add both before deploying with the tag set.

Changing tags on a live deployment changes that device’s identity in the ACL. Grant access under the new tag first, or you lock yourself out of the endpoint until the policy catches up.

Two things to know before switching an existing deployment:

  • The Tailscale device is replaced. Deploy once with ingress.enabled: false so the layer-3 device releases <hostname>, then flip it to true. Otherwise the new device is named <hostname>-1 and the certificate is issued for that name instead.
  • Get it right on the first apply. Let’s Encrypt allows 5 certificates per week for the same name, so a create/delete retry loop can lock you out of the name for days.

Issuing a certificate publishes <hostname>.<tailnet>.ts.net to the public Certificate Transparency log permanently. The service stays private; the name does not.


Authentication

There are two independent ways to decide who reaches this UI, and a deployment picks one.

The network decides. The default. The UI is exposed only on your tailnet, and tailnet ACLs say who may reach it. Nothing in the app authenticates anybody; blazor.auth.enabled stays false and every route is anonymous. This is the right answer when you already run Tailscale.

The app decides. OAuth sign-in, Google today. The UI sits behind an ordinary HTTPS ingress and an allowlist in the app says who gets in. This is for deployments that should not have to adopt Tailscale to be secured.

Setup is a Google Cloud Console walkthrough plus a values block: deploy/blazor-oauth-setup.md.

It is a doorman, not a tenancy model

Everyone who signs in still shares one conversation with the agent — the chat page pins a single SessionId and UserId, and sign-in does not change that. You are deciding who gets in, not whose conversation this is. Per-user sessions would mean reworking agent-side history keying and working-memory namespacing.

The allowlist

blazor:
  auth:
    enabled: true
    publicBaseUrl: "https://rockbot.example.com"
    allowedEmails: [someone@example.com]
    allowedDomains: [example.com]
    google:
      clientId: "...apps.googleusercontent.com"

At least one entry across the two lists is required. The chart refuses to render without one and the app refuses to start.

An empty allowlist does not mean “the people I expect”. “Sign in with Google” with nothing listed means every Google account in existence can open a full agent session — so it is treated as a misconfiguration and fails loudly, rather than coming up wide open.

  • allowedEmails matches the whole address, case-insensitively.
  • allowedDomains matches the part after the final @, exactly. A suffix match would let evil-example.com satisfy an example.com rule.
  • An address Google reports as unverified never matches, on either list.

The allowlist is evaluated on every request, so removing someone takes effect immediately for new requests. An already-open browser tab holds a live SignalR circuit that no longer passes through the request pipeline, so a background revalidation re-checks the allowlist every 30 minutes and tears the circuit down. That window is the worst case for revocation.

Behind a reverse proxy

The one failure this setup reliably produces if you skip it. The pod listens on plain http on :8080, so a callback URL built from the incoming request comes out as http://…, and Google — which permits http only for http://localhost — rejects it with redirect_uri_mismatch and no useful detail.

Set blazor.auth.publicBaseUrl to the external address. Every absolute URL the app builds then comes from that value and no forwarded header needs to be trusted. blazor.auth.trustForwardedHeaders is the fallback for deployments serving several hostnames, and it trusts X-Forwarded-Proto/X-Forwarded-Host from any source — safe only if your ingress strips incoming copies.

Getting in without a tailnet

blazor.ingress is a plain Kubernetes Ingress with a configurable class and pass-through annotations (cert-manager, typically). It is mutually exclusive with blazor.tailscale.ingress — two Ingresses on one Service with different classes is a coin toss, so the chart fails to render if both are on.

Enabling it with blazor.auth.enabled: false also fails to render: that combination publishes a full agent chat session with nothing in front of it. Set blazor.ingress.allowUnauthenticated: true if something outside this chart is doing the gating.

What is protected

[Authorize] and .RequireAuthorization() are applied explicitly, never as a global fallback policy. A fallback policy also covers the MapStaticAssets() endpoints, so anonymous visitors get 302s for the CSS and blazor.web.js and the login page renders broken.

  • The chat page carries [Authorize].
  • /attachments carries .RequireAuthorization(). It serves bytes off the shared PVC; left anonymous it is a side door around sign-in entirely, however well the chat page itself is locked down.
  • /healthz, /login, /access-denied, /auth/challenge are anonymous. The probes point at /healthz rather than /, because / answers a 302 to /login when sign-in is on and a kubelet scores that as success — the probe would pass without asserting anything.

With blazor.auth.enabled: false the default authorization policy succeeds for everyone, so the same attributes stay on the same endpoints and simply pass. There is no second arrangement of the pipeline that only runs when sign-in is off.

Staying signed in

Two independent things, and either one alone gets you nothing:

  • Server side — the data-protection key ring on the PVC (see Persistent storage above). Without it, every restart invalidates every cookie.
  • Client side — the sign-in challenge sets IsPersistent, so the cookie is not a session cookie and closing the browser does not sign you out. blazor.auth.sessionLifetime (14 days, sliding) sets the lifetime.

Sign-in pages

/login and /access-denied are static SSR — deliberately not interactive, so an anonymous visitor never allocates a SignalR circuit. Each provider is a plain link, so the page works with JavaScript disabled; wwwroot/js/login.js only hoists the last-used provider to the top and labels it. It does not auto-redirect: bouncing straight into the remembered provider makes the page impossible to get past when you need to switch accounts, and turns a denied account into a loop you cannot escape from the UI.

/access-denied names the account you signed in as, because the overwhelmingly likely cause is the wrong Google account and a bare 403 leaves you unable to tell which one you used. The session is left intact until you press sign out — there would be nothing to name otherwise.


Configuration

public sealed class UserProxyOptions
{
    public string ProxyId { get; set; }          // Unique identifier for this proxy instance
    public TimeSpan DefaultReplyTimeout { get; set; }  // How long to wait for an agent reply
}

DI registration in Program.cs:

builder.Services.AddRockBotRabbitMq(opts =>
    builder.Configuration.GetSection("RabbitMq").Bind(opts));
builder.Services.AddUserProxy();
builder.Services.AddSingleton<IUserFrontend, BlazorUserFrontend>();
builder.Services.AddSingleton<ChatStateService>();

Message bus topics

Topic Direction Purpose
user.message Blazor → Agent User input
user.response.{proxyId} Agent → Blazor Agent replies (final and intermediate)
user.feedback Blazor → Agent Thumbs-up / thumbs-down
conversation.history.request Blazor → Agent Request history on reconnect
conversation.history.response.{proxyId} Agent → Blazor Correlated history response

© 2026 Marimer LLC. RockBot is released under the MIT license.

This site uses Just the Docs, a documentation theme for Jekyll.