ChatDock Events & Subscriptions: Three Patterns for Agent-to-UI Communication

Eric Brasher February 21, 2026 at 5:45 PM 8 min read

ChatDock handles chat rendering, thinking indicators, and agent lifecycle internally. But most real-world Blazor apps need more — the parent component needs to react when messages arrive: refresh a data table, update a dashboard counter, toggle UI state. FabrCore provides three patterns for this, each designed for a different scope.

Pattern 1: OnMessageReceived Callback (Recommended)

The simplest approach. ChatDock exposes callback parameters that the parent component can bind directly. OnMessageReceived controls whether each message appears in chat — return true to display, false to suppress:

ParameterTypeWhen It Fires
OnMessageReceivedFunc<AgentMessage, Task<bool>>?Agent sends a message (thinking messages excluded). Return true to display, false to suppress.
OnMessageSentEventCallback<string>User message sent successfully

When no callback is set, all non-thinking messages display automatically (backwards compatible). No manual subscribe/unsubscribe or Dispose boilerplate needed.

<ChatDock UserHandle="@_userHandle"
          AgentHandle="document-agent"
          AgentType="DocumentAgent"
          OnMessageReceived="OnAgentResponse"
          OnMessageSent="OnUserMessage" />

@code {
    private Task<bool> OnAgentResponse(AgentMessage message)
    {
        // Full AgentMessage — access .Message, .State, .Args, .MessageType
        _activityLog.Insert(0, $"Agent: {message.Message}");
        return Task.FromResult(true); // show in chat
    }

    private void OnUserMessage(string text)
    {
        _activityLog.Insert(0, $"You: {text}");
    }
}

Filtering messages: Agents often send structured messages (status updates, state changes) that the parent needs to process but should not appear as chat bubbles. Return false to suppress:

@code {
    private Task<bool> HandleMessage(AgentMessage message)
    {
        if (message.MessageType == "status")
        {
            ProcessStatusUpdate(message);
            return Task.FromResult(false); // suppress from chat
        }

        return Task.FromResult(true); // show in chat
    }
}

When to use: Any time a parent component needs to react to or filter messages from a specific ChatDock. This covers 90% of use cases — dashboard updates, activity logs, triggering data refreshes, suppressing non-chat messages.

Tip: OnMessageReceived provides the full AgentMessage object. Use message.State to pass structured metadata from the agent (e.g., State["action"] = "file_created") that the parent can use to decide what to refresh or whether to display.

You can also coordinate multiple ChatDocks by using lambda expressions to identify which agent responded:

<ChatDock AgentHandle="research-agent"
          OnMessageReceived="msg => OnResponse(\"research\", msg)" />

<ChatDock AgentHandle="writer-agent"
          OnMessageReceived="msg => OnResponse(\"writer\", msg)" />

Pattern 2: IClientContext.AgentMessageReceived

If you need to subscribe to all agent messages for a user — not just from one ChatDock — inject IClientContextFactory and subscribe to the AgentMessageReceived event directly. This gives broader visibility than a single ChatDock provides.

@inject IClientContextFactory ClientContextFactory
@implements IAsyncDisposable

@code {
    private IClientContext? _ctx;

    protected override async Task OnInitializedAsync()
    {
        _ctx = await ClientContextFactory
            .GetOrCreateAsync(_userHandle);
        _ctx.AgentMessageReceived += HandleAllMessages;
    }

    private void HandleAllMessages(
        object? sender, AgentMessage msg)
    {
        InvokeAsync(() => { _count++; StateHasChanged(); });
    }

    public async ValueTask DisposeAsync()
    {
        // Unsubscribe — do NOT dispose the shared context
        if (_ctx != null)
            _ctx.AgentMessageReceived -= HandleAllMessages;
    }
}

When to use: Dashboard widgets, global notification systems, audit logging — anywhere you need to see messages from all agents for a user, not just one ChatDock's agent.

Important: AgentMessageReceived fires on Orleans thread pool threads. Always wrap UI updates in InvokeAsync(() => StateHasChanged()). Also: when using GetOrCreateAsync, the context is shared — do not dispose it. Only unsubscribe your handler.

Pattern 3: ChatDockManager.StateChanged

The ChatDockManager fires StateChanged whenever any dock opens or closes. Use it to update surrounding layout, show overlays, or adjust navigation state.

@implements IDisposable

@code {
    private ChatDockManager _mgr = new();

    protected override void OnInitialized()
    {
        _mgr.StateChanged += OnDockStateChanged;
    }

    private void OnDockStateChanged()
    {
        InvokeAsync(StateHasChanged);
    }

    public void Dispose()
    {
        _mgr.StateChanged -= OnDockStateChanged;
    }
}

Check _mgr.ExpandedDockId to know which dock is open (or null if all are collapsed).

When to use: Layout adjustments, overlay dimming, sidebar collapse, or any UI that needs to react to dock expand/collapse — not message content.

Which Pattern Should I Use?

ScenarioPattern
React to messages from a specific ChatDockPattern 1 — EventCallbacks
Listen to all agent messages for a userPattern 2 — IClientContext event
React to dock open/close statePattern 3 — ChatDockManager.StateChanged
Message handling + UI coordinationPatterns 1 + 3 together

Notes

  • Thread safety: Pattern 2 fires on Orleans threads — always use InvokeAsync. Pattern 1 callbacks do not require InvokeAsync.
  • Message filtering: OnMessageReceived (Pattern 1) is a Func<AgentMessage, Task<bool>>?. Return true to display, false to suppress. When null, all non-thinking messages display.
  • Disposal: Pattern 1 requires no cleanup. Patterns 2 and 3 require explicit unsubscribe (-=) in Dispose/DisposeAsync.
  • Thinking messages: OnMessageReceived (Pattern 1) excludes thinking messages. AgentMessageReceived (Pattern 2) delivers all messages including thinking — filter by message.MessageType if needed.

Built with FabrCore on .NET 10.


Eric Brasher

Builder of FabrCore and OpenCaddis.