The last feature on the spec: reception and practitioners chatting inside the app, with a who's-online list and a typing indicator. It's also the part where a perfectly plausible AI design fails with a 401 — and understanding why it fails teaches you more about Blazor Server than any feature that works on the first try. This is the architectural lesson of the series.

Follow along: git checkout part-08 in the companion repo.

The prompt — with a tripwire in it

Verbatim, from the commit "Add staff chat with presence and typing indicators":

Staff chat at /staff/chat, login required: message history from
PostgreSQL (last 50), live messages, who's-online list, and a typing
indicator. My plan was a ChatHub with [Authorize] — tell me if that's
wrong before you build it.

That last sentence is a habit worth stealing: when you have a design in mind, state it and ask the AI to attack it before anyone writes code. It's the cheapest review you'll ever buy — although, as you're about to see, it only works if somebody in the conversation actually catches the flaw.

The plausible design, and the 401 loop

The plan sounds airtight, and the AI's first pass endorsed it happily — plausible designs are an AI's comfort zone. Copy Part 7's homework: a ChatHub, protected with [Authorize] so only staff can connect, and a HubConnection opened from the chat component, exactly like the board did. Every piece is a thing that worked yesterday.

The first attempt did exactly that — and died in a reconnect loop. The hub answered 401, the connection retried, the hub answered 401 again, forever. Nothing was misconfigured. The design itself is wrong, and the reason is the sentence to take away from this entire series:

A HubConnection opened from a server-side Blazor component is a new server-to-server connection — and the browser's auth cookie isn't on it.

Walk it through. In Blazor Server, your component code runs on the server; the browser holds only a thin rendering channel — the circuit. So when a component calls HubConnectionBuilder.Build() and starts it, that HTTP request originates from the server process itself. The user's Identity cookie lives in their browser and travels only on browser requests. The server-to-server hub connection carries no cookie, so [Authorize] sees an anonymous caller and correctly refuses it. Add WithAutomaticReconnect and you've built a very polite infinite loop. Part 7 never hit this for one reason only: QueueHub is anonymous.

The insight: you already have an authenticated connection

Now invert the picture. That chat component is already talking to the server over a live SignalR connection — the circuit — and that circuit belongs to a logged-in user, authenticated the ordinary way when the browser loaded the page. For same-app, logged-in chat, we don't need a second connection with its own auth story. We need the circuits we already have, plus something on the server they can all share: a singleton event bus. Here is ChatRoom.cs, comment and all:

/// <summary>
/// In-process chat state shared by every staff circuit.
///
/// Why no ChatHub? Blazor Server components already ride a SignalR connection —
/// the circuit. A HubConnection opened from a server-side component is a NEW
/// server-to-server connection that does not carry the browser's auth cookie,
/// so an [Authorize] hub answers 401 forever. For same-app, logged-in chat, the
/// circuit + a singleton event bus is the simpler, correct tool; custom hubs
/// (Part 7) are for surfaces that aren't this app's circuits.
/// </summary>
public class ChatRoom
{
    private readonly ConcurrentDictionary<string, int> _online = new();
    private readonly ConcurrentDictionary<string, DateTime> _typing = new();

    public event Action<ChatMessageView>? MessageReceived;
    public event Action? PresenceChanged;
    public event Action? TypingChanged;
    ...

So when does each tool earn its keep? This table is the takeaway of the part:

Custom hub vs circuit + event bus in Blazor Server
Custom SignalR hubCircuit + singleton event bus
Who connects Anything: public screens, kiosks, mobile apps, other services Only your own interactive Blazor components
Authentication The hub handles its own (cookies from browsers, tokens from apps) Already done — the circuit belongs to a logged-in user
Delivery SignalR client, groups, reconnect logic Plain C# events, in-process
In ClinicLive Part 7's board and kiosk This part's staff chat
Honest caveat Scales out with a backplane (e.g. Redis) In-process only — a multi-server farm needs more

The walkthrough

Presence is connection counting, not a boolean — the same user with two tabs open joins twice and must leave twice before they're offline:

public void Join(string name)
{
    _online.AddOrUpdate(name, 1, (_, count) => count + 1);
    PresenceChanged?.Invoke();
}

public void Leave(string name)
{
    if (_online.AddOrUpdate(name, 0, (_, count) => count - 1) <= 0)
    {
        _online.TryRemove(name, out _);
    }
    _typing.TryRemove(name, out _);
    PresenceChanged?.Invoke();
}

Typing is a timestamp with a three-second shelf life — no "stopped typing" message needed, staleness does the cleanup:

public IReadOnlyList<string> TypingUsers =>
    _typing.Where(kv => kv.Value > DateTime.UtcNow.AddSeconds(-3))
           .Select(kv => kv.Key)
           .OrderBy(n => n)
           .ToList();

Sending persists first, then broadcasts — so the history in PostgreSQL and the live feed can never disagree about what was said:

var message = new ChatMessage { SenderId = senderId, SenderName = senderName, Body = body };

await using var db = await dbFactory.CreateDbContextAsync();
db.ChatMessages.Add(message);
await db.SaveChangesAsync();

room.Broadcast(new ChatMessageView(message.SenderName, message.Body, message.SentAt));

And the component, Chat.razor, subscribes on init and — crucially — unsubscribes on dispose. Note InvokeAsync in the handler: the event fires on the sender's circuit, so each subscriber must marshal the update onto its own circuit's dispatcher before touching state:

protected override async Task OnInitializedAsync()
{
    ...
    _messages = await ChatSvc.GetRecentAsync();

    Room.MessageReceived += OnMessage;
    Room.PresenceChanged += OnPresence;
    Room.TypingChanged += OnTypingChanged;
    Room.Join(_me);
    ...
}

private void OnMessage(ChatMessageView m) => _ = InvokeAsync(() =>
{
    _messages.Add(m);
    ...
    StateHasChanged();
});

public void Dispose()
{
    Room.MessageReceived -= OnMessage;
    Room.PresenceChanged -= OnPresence;
    Room.TypingChanged -= OnTypingChanged;
    if (_joined)
    {
        Room.Leave(_me);
    }
}

Leak warning: this pattern has one sharp edge. ChatRoom is a singleton — it lives as long as the app. A component that subscribes to its events and forgets to unsubscribe is kept alive by the singleton after its circuit dies: a memory leak, plus event handlers firing on disposed components. If a component subscribes to a longer-lived object's events, @implements IDisposable and the matching -= lines are not optional.

The verified moment

Two browsers, two logins: reception@cliniclive.test and practitioner@cliniclive.test. Both names in the online list. Start typing as the practitioner and "practitioner typing…" appears live in reception's tab; send, and the message lands in both — then check the receipts in psql: a row in chat_messages, its sent_at a proper timestamptz, exactly as Part 4's schema rules demanded. Log one user out and their name drops off the presence list. No hub, no 401, no reconnect loop.

Model pick: Opus, high effort — not for the code, which is modest, but for the design conversation. This part's value was one architectural judgment; that's precisely the work worth pointing the strongest model at, and precisely the work you double-check hardest, because it sounds most convincing when wrong.

What the AI got wrong: two things, and they rhyme. First, the headline: it endorsed and built the [Authorize] ChatHub design that can only ever 401 — pattern-matching Part 7's success into a context where its key assumption (anonymous clients) no longer held. Second, CS0542 again: it injected a member named Chat into Chat.razor, the identical mistake it made with Queue/Queue.razor last part. AI doesn't learn between your sessions — it repeats its habits, and noticing an assistant's recurring failure patterns is now a genuine engineering skill. Your mistake log is exactly as valuable as your prompt log.

The meter: the design discussion, the failed first attempt, the rebuild on the circuit model and the walkthrough added about $0.70, bringing the total to ≈ $3.40. The failed attempt is included in that number on purpose — dead ends are part of the real cost of AI development, and this series counts them.

Checkpoint: two logged-in users chatting live at /staff/chat; presence and the typing indicator update across tabs; messages survive a restart because they're rows in chat_messages; and you can explain, in one sentence, why a server-side HubConnection can't pass an [Authorize] check. That sentence is the part.

ClinicLive is now feature-complete: booking, kiosk, live board, staff queue, chat. It demos beautifully — and two quiet bugs from Parts 6 and 7 are riding along, with not a single automated test standing in their way. Time to fix the second half of that sentence, and to meet the trap that makes AI-written tests dangerously agreeable: Part 9: testing AI-written code without fooling yourself.