Home / Docs / Blazor

Blazor

Blazor Integration

FabrCore provides Blazor Server (Interactive Server) components for building AI-powered UIs: a drop-in ChatDock floating chat panel and FabrCoreClientAgent for embedding agents directly into components.

Requires Interactive Server Render Mode

All FabrCore.Client components require Blazor's Interactive Server render mode (.AddInteractiveServerComponents() / .AddInteractiveServerRenderMode()). They rely on persistent SignalR connections for real-time Orleans communication and are not compatible with Blazor WebAssembly or static SSR.

ChatDock

A reusable floating chat interface with markdown rendering, agent health display, and multi-dock coordination.

Setup

Program.cs
builder.AddFabrCoreClient();
builder.Services.AddFabrCoreClientComponents();
App.razor — Add CSS
<link rel="stylesheet"
      href="_content/FabrCore.Client/fabr.css" />

Basic Usage

ChatPage.razor
<CascadingValue Value="@DockManager">
    <ChatDock UserHandle="@_userId"
              AgentHandle="assistant"
              AgentType="MyAssistantAgent"
              Title="AI Assistant"
              Icon="bi bi-robot"
              WelcomeMessage="Hello! How can I help?" />
</CascadingValue>

Parameters

ParameterTypeRequiredDescription
UserHandlestringYesCurrent user identifier
AgentHandlestringYesAgent handle to connect to
AgentTypestringYesAgent type to create
SystemPromptstringNoSystem prompt (default: helpful assistant)
TitlestringNoDisplay title in chat header
IconstringNoBootstrap icon class
WelcomeMessagestringNoMessage shown when chat is empty
PositionChatDockPositionNoPanel position (BottomRight, BottomLeft, Right, Left)
AdditionalArgsDictionaryNoExtra args passed to agent configuration
OnMessageReceivedFunc<AgentMessage, Task<bool>>?NoInvoked when agent sends a message (excludes thinking messages). Return true to display in chat, false to suppress. When null, all messages display.
OnMessageSentEventCallback<string>NoInvoked after user message is sent successfully

Position Options

ValueDescription
BottomRightPanel slides up from bottom-right corner (default)
BottomLeftPanel slides up from bottom-left corner
RightPanel slides in from right edge, full viewport height
LeftPanel slides in from left edge, full viewport height

Keyboard Support

KeyAction
EnterSend message
Shift+EnterInsert newline
EscapeCollapse chat panel

Agent Health Display

The ChatDock footer shows live agent health (refreshed every 30 seconds): Healthy, Degraded, Unhealthy, or Starting...

ChatDockManager

Coordinates multiple ChatDock instances on the same page — only one dock can be expanded at a time.

Multiple Docks
<CascadingValue Value="@DockManager">
    <ChatDock UserHandle="@_userId"
              AgentHandle="support"
              AgentType="SupportAgent"
              Title="Support"
              Icon="bi bi-headset" />

    <ChatDock UserHandle="@_userId"
              AgentHandle="docs"
              AgentType="DocAgent"
              Title="Documentation"
              Icon="bi bi-book" />
</CascadingValue>
MethodDescription
Register(dockId, callback)Register a dock with the manager
Unregister(dockId)Unregister a dock
Expand(dockId)Expand a specific dock (collapses others)
Collapse(dockId)Collapse a specific dock
Toggle(dockId)Toggle dock expansion state
IsExpanded(dockId)Check if a dock is expanded

FabrCoreClientAgent

FabrCoreClientAgentProxy<TComponent> enables creating AI agents that live inside Blazor components with direct access to component state and UI.

Step 1: Define the Agent Proxy

OrderDetailsAgentProxy.cs
public class OrderDetailsAgentProxy
    : FabrCoreClientAgentProxy<OrderDetails>
{
    private AIAgent? _agent;
    private AgentThread? _thread;

    public override async Task OnInitializeAsync()
    {
        var modelConfig = await fabrHostApiClient
            .GetModelConfigAsync("default");
        var apiKey = await fabrHostApiClient
            .GetApiKeyAsync(modelConfig.ApiKeyAlias);

        IChatClient chatClient = new OpenAIChatClient(
            model: modelConfig.Model, apiKey: apiKey.Value);

        _agent = new ChatClientAgent(chatClient,
            new ChatClientAgentOptions
            {
                Name = Handle,
                ChatOptions = new ChatOptions
                {
                    Instructions = "You are an order assistant.",
                    Tools = [AIFunctionFactory.Create(UpdateStatus)]
                }
            })
            .AsBuilder()
            .UseOpenTelemetry(null, cfg => cfg.EnableSensitiveData = true)
            .Build(serviceProvider);

        _thread = _agent.GetNewThread();
    }

    [Description("Updates the order status.")]
    private async Task<string> UpdateStatus(
        [Description("The new status")] string status)
    {
        await InvokeAsync(() =>
        {
            Component.OrderStatus = status;
            RequestComponentUpdate();
        });
        return $"Status updated to '{status}'";
    }
}

Step 2: Use in a Component

OrderDetails.razor
<div class="order-details">
    <h3>Order: @OrderId</h3>
    <strong>Status:</strong> @OrderStatus
</div>

<OrderDetailsAgent
    Component="this"
    Handle="@($\"order-agent-{OrderId}\")" />
Thread Safety

Always use InvokeAsync when modifying component state from agent callbacks. Blazor Server requires UI updates to happen on the component's synchronization context.

Context-Aware Usage

When integrating ChatDock into entity-specific pages, use @key and AdditionalArgs to ensure proper isolation:

EntityPage.razor — Entity-Scoped ChatDock
@page "/entity/{EntityId:guid}"

<ChatDock @key="@($\"chatdock-{EntityId}\")"
          UserHandle="@_userId"
          AgentHandle="@($\"editor-{EntityId}\")"
          AgentType="EditorAgent"
          Title="Editor Assistant"
          Position="ChatDockPosition.Right"
          AdditionalArgs="@GetAgentArgs()" />

@code {
    [Parameter] public Guid EntityId { get; set; }
    private string _userId = "user-123";

    private Dictionary<string, string> GetAgentArgs() => new()
    {
        ["entityId"] = EntityId.ToString(),
        ["userId"] = _userId
    };
}
Key Points
  • Use @key to force re-render when context changes (e.g., navigating between entities)
  • Include entity IDs in the agent handle for isolation between contexts
  • Pass context data via AdditionalArgs so agents can access entity-specific information

Styling

ChatDock uses CSS custom properties with fallbacks for easy theming. Override these variables in your app's CSS:

CSS Custom Properties
:root {
    /* Primary colors */
    --chat-dock-primary: #3b82f6;
    --chat-dock-secondary: #1e3a5f;
    --chat-dock-text-on-dark: #ffffff;

    /* Background colors */
    --chat-dock-surface: #ffffff;
    --chat-dock-bg: #f3f4f6;

    /* Status colors */
    --chat-dock-success: #22c55e;
    --chat-dock-warning: #f59e0b;
    --chat-dock-danger: #ef4444;

    /* Text colors */
    --chat-dock-text: #1f2937;
    --chat-dock-text-muted: #6b7280;

    /* Borders */
    --chat-dock-border: #e5e7eb;
    --chat-dock-border-strong: #d1d5db;

    /* Border radii */
    --chat-dock-radius-sm: 4px;
    --chat-dock-radius-md: 8px;
    --chat-dock-radius-lg: 12px;

    /* Spacing */
    --chat-dock-spacing-1: 4px;
    --chat-dock-spacing-2: 8px;
    --chat-dock-spacing-3: 12px;
    --chat-dock-spacing-4: 16px;

    /* Effects */
    --chat-dock-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
    --chat-dock-focus-ring: 0 0 0 3px rgba(59, 130, 246, 0.2);
}

The component also respects app-level CSS variables (--app-color-primary, --app-text-on-dark, etc.) as fallbacks.

Message Types

ChatDock routes messages based on MessageType. The OnMessageReceived callback controls whether non-thinking messages appear in chat history:

MessageTypeChatDock Behavior
"thinking"Shows as typing indicator (auto-fades). Handled internally — callback is not invoked.
null or "chat"Callback receives it. Displayed in chat by default.
"status"Callback receives it. Agent convention for status updates — suppressing recommended.
(any other value)Callback receives it. Consumer decides whether to display.
Message Filtering

OnMessageReceived is a Func<AgentMessage, Task<bool>>?. Return true to show the message in chat, false to suppress it. When no callback is set, all non-thinking messages display automatically (backwards compatible for simple usage).

Agents can send "thinking" messages to provide feedback during long operations:

Sending Thinking Indicator from Agent
await fabrAgentHost.SendMessage(new AgentMessage
{
    FromHandle = fabrAgentHost.GetHandle(),
    ToHandle = userHandle,
    MessageType = "thinking",
    Message = "Searching documents..."
});

ChatDock Events & Subscriptions

The ChatDock component provides multiple ways for parent components to receive notifications when messages are sent or received. Choose the pattern that best fits your scenario.


Pattern 1: OnMessageReceived Callback Recommended

The simplest approach. Add callback parameters directly on the <ChatDock> tag. OnMessageReceived controls whether each message appears in chat history — return true to display, false to suppress.

ParameterTypeDescription
OnMessageReceivedFunc<AgentMessage, Task<bool>>?Invoked when the agent sends a message (thinking messages excluded). Return true to display in chat, false to suppress. When null, all messages display. Provides the full AgentMessage with metadata (FromHandle, MessageType, State, etc.).
OnMessageSentEventCallback<string>Invoked after the user's message is successfully sent to the agent. Provides the trimmed message text. Does not fire if sending fails.

Example — Show all messages (preserve default behavior):

Documents.razor — Show All Messages
@page "/documents"

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

<h3>Recent Activity</h3>
<ul>
    @foreach (var entry in _activityLog)
    {
        <li>@entry</li>
    }
</ul>

@code {
    private string _userHandle = "user-123";
    private List<string> _activityLog = new();

    private Task<bool> OnAgentResponse(AgentMessage message)
    {
        _activityLog.Insert(0,
            $"[{DateTime.Now:HH:mm:ss}] Agent: {message.Message}");

        // Access metadata from the agent
        if (message.State?.TryGetValue("action",
            out var action) == true)
        {
            _activityLog.Insert(0,
                $"  Action performed: {action}");
        }

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

    private void OnUserMessage(string text)
    {
        _activityLog.Insert(0,
            $"[{DateTime.Now:HH:mm:ss}] You: {text}");
    }
}

Example — Filtering status messages from chat:

Dashboard.razor — Message Filtering
<ChatDock UserHandle="@_userHandle"
          AgentHandle="dashboard-agent"
          AgentType="DashboardAgent"
          OnMessageReceived="HandleMessage" />

@code {
    private string _userHandle = "user-123";

    private Task<bool> HandleMessage(AgentMessage message)
    {
        if (message.MessageType == "status")
        {
            // Process for activity log, state updates, etc.
            ProcessStatusUpdate(message);
            return Task.FromResult(false); // suppress from chat
        }

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

Example — Coordinating multiple ChatDocks:

Multi-Dock Coordination
<ChatDock UserHandle="@_userHandle"
          AgentHandle="research-agent"
          AgentType="ResearchAgent"
          Title="Research"
          OnMessageReceived="msg => OnAgentResponse(\"research\", msg)" />

<ChatDock UserHandle="@_userHandle"
          AgentHandle="writer-agent"
          AgentType="WriterAgent"
          Title="Writer"
          OnMessageReceived="msg => OnAgentResponse(\"writer\", msg)" />

<p>Last active agent: @_lastActiveAgent</p>

@code {
    private string _userHandle = "user-123";
    private string _lastActiveAgent = "(none)";

    private Task<bool> OnAgentResponse(
        string agentName, AgentMessage message)
    {
        _lastActiveAgent = agentName;
        return Task.FromResult(true);
    }
}

Pattern 2: IClientContext.AgentMessageReceived Event

If you need to subscribe to all agent messages for a user — not just from a specific ChatDock — inject IClientContextFactory and subscribe to the AgentMessageReceived event on the IClientContext directly. This is useful when a parent component or service needs broader visibility than a single ChatDock provides.

Dashboard.razor — IClientContext Pattern
@page "/dashboard"
@using FabrCore.Client
@using FabrCore.Core
@inject IClientContextFactory ClientContextFactory
@implements IAsyncDisposable

<ChatDock UserHandle="@_userHandle"
          AgentHandle="support-agent"
          AgentType="SupportAgent" />

<p>Total messages received: @_messageCount</p>

@code {
    private string _userHandle = "user-123";
    private IClientContext? _clientContext;
    private int _messageCount;

    protected override async Task OnInitializedAsync()
    {
        // Get the shared context for this user
        _clientContext = await ClientContextFactory
            .GetOrCreateAsync(_userHandle);

        // Subscribe to ALL agent messages for this user
        _clientContext.AgentMessageReceived += HandleAllMessages;
    }

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

    public async ValueTask DisposeAsync()
    {
        // Always unsubscribe to prevent memory leaks
        if (_clientContext != null)
        {
            _clientContext.AgentMessageReceived -= HandleAllMessages;
            // Do NOT dispose — the factory manages its lifecycle
        }
    }
}
Context Lifecycle

When using GetOrCreateAsync, the context is shared and managed by the factory. Do not dispose it yourself — only unsubscribe your event handler. If you use CreateAsync instead, you own the context and must dispose it.


Pattern 3: ChatDockManager.StateChanged Event

The ChatDockManager coordinates expand/collapse state across multiple ChatDock instances and fires StateChanged when any dock opens or closes. This is useful for updating surrounding layout or navigation UI.

Layout with DockManager.StateChanged
@using FabrCore.Client.Services
@implements IDisposable

<CascadingValue Value="_dockManager">
    <ChatDock UserHandle="@_userHandle"
              AgentHandle="agent-a"
              AgentType="AgentA"
              Title="Agent A" />
    <ChatDock UserHandle="@_userHandle"
              AgentHandle="agent-b"
              AgentType="AgentB"
              Title="Agent B" />
</CascadingValue>

@if (_dockManager.ExpandedDockId != null)
{
    <div class="overlay-active">A chat panel is open</div>
}

@code {
    private string _userHandle = "user-123";
    private ChatDockManager _dockManager = new();

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

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

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

Which Pattern Should I Use?

ScenarioRecommended Pattern
React to or filter messages from a specific ChatDockPattern 1 — OnMessageReceived callback / OnMessageSent EventCallback
Listen to all agent messages for a user across the appPattern 2 — IClientContext.AgentMessageReceived event
React to dock open/close state changesPattern 3 — ChatDockManager.StateChanged event
Combine message handling with UI coordinationPatterns 1 + 3 together
Notes
  • Thread safety: IClientContext.AgentMessageReceived is invoked on Orleans thread pool threads. Always wrap UI updates in InvokeAsync(() => StateHasChanged()) when using Pattern 2.
  • 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: Always unsubscribe from events (-=) in Dispose / DisposeAsync to prevent memory leaks. Pattern 1 callbacks do not require manual cleanup.

Example: Multi-Agent ChatDock

A complete walkthrough of a Blazor app with two ChatDock components — each connected to a separate agent with a different persona. Both use the same agent type but maintain independent conversations.

Architecture

Multi-Agent Layout
┌─────────────────────────────────────────────────────┐
│                   Blazor Page                       │
│  ┌──────────────┐          ┌──────────────┐         │
│  │  ChatDock #1 │          │  ChatDock #2 │         │
│  │  "Support"   │          │  "Sales"     │         │
│  │  handle: a1  │          │  handle: a2  │         │
│  └──────┬───────┘          └──────┬───────┘         │
│         └───────────┬─────────────┘                 │
│              ChatDockManager                        │
│          (only one open at a time)                  │
├─────────────────────┼───────────────────────────────┤
│              ClientContext                          │
│  ┌──────────────────┴──────────────────┐            │
│  │         Orleans Silo                │            │
│  │  ┌────────────┐  ┌────────────┐    │            │
│  │  │  Agent a1  │  │  Agent a2  │    │            │
│  │  │  (Support) │  │  (Sales)   │    │            │
│  │  └────────────┘  └────────────┘    │            │
│  └─────────────────────────────────────┘            │
└─────────────────────────────────────────────────────┘

Program.cs

Program.cs — Combined Server + Client
var builder = WebApplication.CreateBuilder(args);

// Server (Orleans silo + REST API)
builder.AddFabrCoreServer(new FabrCoreServerOptions
{
    AdditionalAssemblies = [typeof(AssistantAgent).Assembly]
});

// Client (connects to local silo)
builder.AddFabrCoreClient();
builder.Services.AddFabrCoreClientComponents();

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();
builder.Services.AddControllers()
    .AddApplicationPart(typeof(FabrCoreServerOptions).Assembly);

var app = builder.Build();

app.UseFabrCoreServer();
app.UseFabrCoreClient();
app.MapControllers();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

Agent

AssistantAgent.cs — Reusable Agent with Persona
[AgentAlias("AssistantAgent")]
public sealed class AssistantAgent : FabrCoreAgentProxy
{
    private IChatClient? _chatClient;
    private List<ChatMessage> _history = new();

    public override async Task OnInitialize()
    {
        _chatClient = await GetChatClient("OpenAIProd");
    }

    public override async Task<AgentMessage> OnMessage(
        AgentMessage message)
    {
        var response = message.Response();

        // Send thinking indicator
        await fabrAgentHost.SendMessage(new AgentMessage
        {
            FromHandle = fabrAgentHost.GetHandle(),
            ToHandle = message.FromHandle,
            MessageType = "thinking",
            Message = "Thinking..."
        });

        _history.Add(new ChatMessage(ChatRole.User,
            message.Message));

        var messages = new List<ChatMessage>
        {
            new(ChatRole.System,
                config.SystemPrompt ?? "You are helpful.")
        };
        messages.AddRange(_history);

        var result = await _chatClient!.GetResponseAsync(messages);
        _history.Add(new(ChatRole.Assistant, result.Text));

        response.Message = result.Text;
        return response;
    }
}

Home Page

Home.razor — Dual ChatDocks
@page "/"
@rendermode InteractiveServer
@inject ChatDockManager DockManager

<CascadingValue Value="@DockManager">
    <div class="chat-dock-container">
        <ChatDock UserHandle="@UserId"
                  AgentHandle="support-agent"
                  AgentType="AssistantAgent"
                  Title="Support"
                  Icon="bi bi-headset"
                  SystemPrompt="@SupportPrompt"
                  Position="ChatDockPosition.BottomRight" />

        <ChatDock UserHandle="@UserId"
                  AgentHandle="sales-agent"
                  AgentType="AssistantAgent"
                  Title="Sales"
                  Icon="bi bi-currency-dollar"
                  SystemPrompt="@SalesPrompt"
                  Position="ChatDockPosition.BottomRight" />
    </div>
</CascadingValue>

@code {
    private const string UserId = "demo-user";
    private const string SupportPrompt = "You are a support assistant.";
    private const string SalesPrompt = "You are a sales assistant.";
}
Key Concepts
  • Agent Handle = Unique Instance — Each AgentHandle creates a separate agent. The full handle format is {UserHandle}:{AgentHandle}.
  • SystemPrompt = Persona — Same agent type with different prompts produces different behaviors.
  • ChatDockManager — Ensures only one dock is expanded at a time when wrapped in CascadingValue.

Example: Human-in-the-Loop

A complete Human-in-the-Loop (HITL) pattern where agents pause execution and request human approval before executing sensitive operations.

Message Flow

HITL Approval Flow
┌──────────┐     ┌────────────┐     ┌──────────────────┐
│  User    │     │  Chat UI   │     │  HitlAgentProxy  │
│  (UI)    │     │  (Client)  │     │  (Orleans Grain)  │
└────┬─────┘     └──────┬─────┘     └────────┬─────────┘
     │                  │                     │
     │ "Cancel order"   │                     │
     │─────────────────>│ SendAndReceive      │
     │                  │────────────────────>│
     │                  │                     │ Runs AI Agent
     │                  │                     │ Detects approval
     │                  │                     │ Stores in state
     │                  │ approval_request    │
     │ Show approval UI │<────────────────────│
     │<─────────────────│                     │
     │                  │                     │
     │ Click "Approve"  │                     │
     │─────────────────>│ approval_response   │
     │                  │────────────────────>│
     │                  │                     │ Clears pending
     │                  │                     │ Executes tool
     │                  │ assistant response  │
     │ Show response    │<────────────────────│
     │<─────────────────│                     │

HITL Agent

The agent wraps sensitive tools in ApprovalRequiredAIFunction and persists pending approvals to Orleans grain state:

HitlAgentProxy.cs — Key Setup
[AgentAlias("HitlAgent")]
public sealed class HitlAgentProxy : FabrCoreAgentProxy
{
    private const string PendingApprovalKey
        = "hitl.pending_approval";

    public override async Task OnInitialize()
    {
        // Safe tools — no approval needed
        var safeTools = new AIFunction[]
        {
            AIFunctionFactory.Create(
                _orderTools.GetOrderDetails)
        };

        // Wrap dangerous tools in ApprovalRequired
        var approvalTools = new AIFunction[]
        {
            new ApprovalRequiredAIFunction(
                AIFunctionFactory.Create(
                    _orderTools.CancelOrder)),
            new ApprovalRequiredAIFunction(
                AIFunctionFactory.Create(
                    _orderTools.ChangeOrderPriority))
        };

        (_agent, _thread) = await CreateChatClientAgent(
            "OpenAIProd",
            tools: safeTools.Concat(approvalTools)
                .Cast<AITool>().ToList());
    }
}

State Persistence

Pending approvals are stored in Orleans grain state using SetState / GetStateAsync:

Persisting Approval State
// When approval is needed — persist before responding
SetState(PendingApprovalKey, pending);
await FlushStateAsync();

// When approval arrives — clear before processing
RemoveState(PendingApprovalKey);
await FlushStateAsync();

Approval UI

The Blazor page renders approval cards with Approve/Reject buttons when it receives an approval_request message type:

Chat.razor — Approval Card
@if (msg.IsApprovalRequest)
{
    <div class="card border-warning">
        <div class="card-header bg-warning">
            Approval Required
        </div>
        <div class="card-body">
            <p>Function: @msg.ApprovalData.FunctionName</p>
            <pre>@msg.ApprovalData.ArgumentsJson</pre>

            <button class="btn btn-success"
                    @onclick="() => HandleApproval(id, true)">
                Approve
            </button>
            <button class="btn btn-danger"
                    @onclick="() => HandleApproval(id, false)">
                Reject
            </button>
        </div>
    </div>
}

MessageType Protocol

MessageTypeDirectionPurpose
userUI → AgentNormal user message
approval_requestAgent → UIAgent needs human approval
approval_responseUI → AgentHuman's approval decision
assistantAgent → UINormal agent response
Best Practices
  • Always show tool details — Display function name and arguments, never just "Approve?"
  • Persist before responding — Call FlushStateAsync() after SetState() before returning
  • Clear before continuing — Remove pending approval before processing to prevent double-execution
  • Add timeouts — Expire pending approvals after a reasonable period (e.g., 1 hour)
Documentation