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.
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
builder.AddFabrCoreClient();
builder.Services.AddFabrCoreClientComponents();
<link rel="stylesheet"
href="_content/FabrCore.Client/fabr.css" />
Basic Usage
<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
| Parameter | Type | Required | Description |
|---|---|---|---|
UserHandle | string | Yes | Current user identifier |
AgentHandle | string | Yes | Agent handle to connect to |
AgentType | string | Yes | Agent type to create |
SystemPrompt | string | No | System prompt (default: helpful assistant) |
Title | string | No | Display title in chat header |
Icon | string | No | Bootstrap icon class |
WelcomeMessage | string | No | Message shown when chat is empty |
Position | ChatDockPosition | No | Panel position (BottomRight, BottomLeft, Right, Left) |
AdditionalArgs | Dictionary | No | Extra args passed to agent configuration |
OnMessageReceived | Func<AgentMessage, Task<bool>>? | No | Invoked when agent sends a message (excludes thinking messages). Return true to display in chat, false to suppress. When null, all messages display. |
OnMessageSent | EventCallback<string> | No | Invoked after user message is sent successfully |
Position Options
| Value | Description |
|---|---|
BottomRight | Panel slides up from bottom-right corner (default) |
BottomLeft | Panel slides up from bottom-left corner |
Right | Panel slides in from right edge, full viewport height |
Left | Panel slides in from left edge, full viewport height |
Keyboard Support
| Key | Action |
|---|---|
| Enter | Send message |
| Shift+Enter | Insert newline |
| Escape | Collapse 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.
<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>
| Method | Description |
|---|---|
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
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
<div class="order-details">
<h3>Order: @OrderId</h3>
<strong>Status:</strong> @OrderStatus
</div>
<OrderDetailsAgent
Component="this"
Handle="@($\"order-agent-{OrderId}\")" />
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:
@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
};
}
- Use
@keyto 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
AdditionalArgsso 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:
: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:
| MessageType | ChatDock 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. |
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:
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.
| Parameter | Type | Description |
|---|---|---|
OnMessageReceived | Func<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.). |
OnMessageSent | EventCallback<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):
@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:
<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:
<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.
@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
}
}
}
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.
@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?
| Scenario | Recommended Pattern |
|---|---|
| React to or filter messages from a specific ChatDock | Pattern 1 — OnMessageReceived callback / OnMessageSent EventCallback |
| Listen to all agent messages for a user across the app | Pattern 2 — IClientContext.AgentMessageReceived event |
| React to dock open/close state changes | Pattern 3 — ChatDockManager.StateChanged event |
| Combine message handling with UI coordination | Patterns 1 + 3 together |
- Thread safety:
IClientContext.AgentMessageReceivedis invoked on Orleans thread pool threads. Always wrap UI updates inInvokeAsync(() => StateHasChanged())when using Pattern 2. - Message filtering:
OnMessageReceived(Pattern 1) is aFunc<AgentMessage, Task<bool>>?. Returntrueto display,falseto suppress. When null, all non-thinking messages display. - Disposal: Always unsubscribe from events (
-=) inDispose/DisposeAsyncto 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
┌─────────────────────────────────────────────────────┐
│ 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
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
[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
@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.";
}
- Agent Handle = Unique Instance — Each
AgentHandlecreates 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
┌──────────┐ ┌────────────┐ ┌──────────────────┐
│ 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:
[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:
// 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:
@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
| MessageType | Direction | Purpose |
|---|---|---|
user | UI → Agent | Normal user message |
approval_request | Agent → UI | Agent needs human approval |
approval_response | UI → Agent | Human's approval decision |
assistant | Agent → UI | Normal agent response |
- Always show tool details — Display function name and arguments, never just "Approve?"
- Persist before responding — Call
FlushStateAsync()afterSetState()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)