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...
Panel Size
Control the size of the expanded chat panel using the ChatDockSize parameter:
| Value | Description |
|---|---|
Small | Compact panel for quick interactions |
Medium | Default panel size |
Large | Wider panel for detailed conversations |
Full | Full viewport width and height |
<ChatDock UserHandle="@_userId"
AgentHandle="assistant"
AgentType="MyAssistantAgent"
ChatDockSize="ChatDockSize.Large"
Title="AI Assistant" />
Document Upload
ChatDock supports drag-and-drop document uploads. Users can drag files directly onto the chat panel to attach them to the conversation. Uploaded files are sent to the FabrCore file storage API and their file IDs are included in the AgentMessage.Files collection for the agent to process.
Agent Reset
The chat panel header includes a reset button that clears the current conversation state, allowing users to start a fresh interaction with the agent. This resets the chat history displayed in the panel and reinitializes the agent connection.
Plugins and Tools
When an agent is configured with plugins or tools, their output is rendered directly within the ChatDock panel. Use the Plugins and Tools parameters to enable them when creating the ChatDock:
<ChatDock UserHandle="@_userId"
AgentHandle="assistant"
AgentType="MyAssistantAgent"
ChatDockSize="ChatDockSize.Large"
Plugins="@(new List<string> { \"weather\", \"calendar\" })"
Tools="@(new List<string> { \"calculate\" })"
Title="AI Assistant"
Position="ChatDockPosition.Right" />
Plugins and Tools accept lists of alias strings matching [PluginAlias] and [ToolAlias] values respectively. These are passed through to the AgentConfiguration when the agent is created. Tool results from plugins and standalone tools are rendered inline within the chat panel.
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)