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