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.messageto send user input to the agent - Publish
user.feedbackto send thumbs-up / thumbs-down signals - Publish
conversation.history.requestand 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:
- Marks the message in
ChatStateService(disabling the buttons to prevent double-voting) - Publishes a
UserFeedbackmessage to RabbitMQ - The agent receives it as a
FeedbackSignalType.Correction(👎) orThumbsUpsignal
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
subPathon the shared PVC. That volume is co-mounted into ephemeral script pods running LLM-authored code, and it is swept by theshared-cleanupCronJob, whose catch-all rule deletes anything older thanshared.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 podPending. This also meansblazor.replicaCountmust stay at 1 — which it already had to, since conversation state lives in the in-memoryChatStateService.
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:
- The tag must exist in
tagOwners. - 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
tagson 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: falseso the layer-3 device releases<hostname>, then flip it totrue. Otherwise the new device is named<hostname>-1and 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.
allowedEmailsmatches the whole address, case-insensitively.allowedDomainsmatches the part after the final@, exactly. A suffix match would letevil-example.comsatisfy anexample.comrule.- 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]. /attachmentscarries.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/challengeare anonymous. The probes point at/healthzrather than/, because/answers a 302 to/loginwhen 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 |