Blazor UI Integration
Blazor UI Integration
Build Blazor experiences as application-owned UI over FabrCore's REST and WebSocket contracts. The old FabrCore.Client component package is obsolete; v1.7.1 provides a clean split between FabrCore.Sdk for commands and queries and FabrCore.Client.WebSocket for real-time delivery. Your application owns the components, the state, and the identity mapping.
Map the authenticated user to one stable FabrCore principal handle in your server-side application service. Do not accept arbitrary principal or agent prefixes from a browser component.
Server-Side Pattern
Blazor Server gives you something a browser SPA cannot have: the FabrCore calls run on your server, inside an authenticated circuit. That means the Host base address, the admin API key, and the principal mapping never reach the client. The whole integration reduces to one scoped service per circuit.
- Resolve the signed-in user's principal handle in the Blazor server.
- Use
IFabrCoreHostApiClientfor agent creation, Blueprints, health, chat, storage, and diagnostics. - Maintain one typed WebSocket v2 connection per active user session when the page needs progress or unsolicited updates.
- Copy received events into application state, then call
InvokeAsync(StateHasChanged)from the component. - Dispose the connection with the circuit or scoped service.
Register the SDK client and the application service as scoped, so each circuit gets its own instance bound to that user:
builder.Services.AddHttpContextAccessor();
// The SDK client is configured once with the Host address. Authentication is attached
// per request by the application service, not baked into the registration.
builder.Services.AddHttpClient("fabrcore", http =>
{
http.BaseAddress = new Uri(builder.Configuration["FabrCore:HostUri"]!);
});
builder.Services.AddScoped<AgentSessionService>();
Remote model-configuration and Harness Skills administration endpoints require the FabrCoreAdmin policy. Keep FabrCore:AdminAuthentication:ApiKey in server configuration only, and expose narrow application methods rather than the raw SDK client to components.
REST API
Provisioning is an HTTP concern. Create agents, apply Blueprints, read health, and query history through IFabrCoreHostApiClient before any socket is opened — WebSocket v2 deliberately excludes agent creation and reconfiguration.
public sealed class AgentSessionService(
IFabrCoreHostApiClient api,
IHttpContextAccessor accessor)
{
// One stable principal handle per signed-in user. Derive it from a durable claim,
// never from anything the browser can set.
private string PrincipalHandle =>
accessor.HttpContext?.User.FindFirst("oid")?.Value
?? throw new InvalidOperationException("No authenticated principal.");
public async Task<string> EnsureAssistantAsync(CancellationToken ct)
{
var handle = $"{PrincipalHandle}:assistant";
if (await api.IsAgentTrackedAsync(handle, ct))
{
return handle;
}
await api.CreateAgentAsync(handle, "assistant", ct);
return handle;
}
}
Handles use the principalHandle:agentHandle form. The principal is the partition for tracking, access control, durable delivery, and agent state, so deriving it from a stable claim is what keeps a user's agents and history attached to them across sessions and devices.
WebSocket Streaming
A server-side Blazor service can safely request the 30-second, single-use ticket and establish the v2 subprotocol without exposing administrative credentials. Use a stable clientId and persist the last acknowledged sequence when the experience must recover messages after a circuit reconnect.
The read loop runs in the background for the life of the circuit. Deliveries arrive on a thread pool thread, so every state mutation has to be marshalled back onto the renderer with InvokeAsync:
public sealed class AgentStreamService : IAsyncDisposable
{
private readonly CancellationTokenSource _cts = new();
private FabrCoreWebSocketClient? _client;
private Task? _pump;
// Raised on a background thread. Components must marshal before touching state.
public event Func<AgentMessage, Task>? MessageReceived;
public async Task StartAsync(HttpClient authenticated, string clientId)
{
_client = new FabrCoreWebSocketClient(
authenticated,
new FabrCoreWebSocketClientOptions
{
HostUri = new Uri("https://agents.example.com"),
// Stable per user + browser profile, so a reconnect resumes that
// client's backlog instead of starting at the tail.
ClientId = clientId
});
_client.ResyncRequired += (_, _) =>
{
// Retention dropped a required record. Rebuild from HTTP, do not guess.
_ = ReloadHistoryFromRestAsync();
};
await _client.ConnectAsync(_cts.Token);
_pump = PumpAsync(_cts.Token);
}
private async Task PumpAsync(CancellationToken ct)
{
await foreach (var delivery in _client!.ReadDeliveriesAsync(ct))
{
if (MessageReceived is not null)
{
await MessageReceived(delivery.Message);
}
// Acknowledge only after the state change is durable on your side.
// Delivery is at-least-once, so handling must be idempotent.
await _client.AcknowledgeAsync(delivery.Sequence, ct);
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_pump is not null) { try { await _pump; } catch (OperationCanceledException) { } }
if (_client is not null) { await _client.DisposeAsync(); }
_cts.Dispose();
}
}
The component subscribes, marshals, and unsubscribes. Implementing IAsyncDisposable on the component is what prevents a torn-down circuit from being handed a delivery:
@implements IAsyncDisposable
@inject AgentStreamService Stream
<ul>
@foreach (var m in _messages)
{
<li @key="m.Id">@m.Message</li>
}
</ul>
@code {
private readonly List<AgentMessage> _messages = new();
protected override void OnInitialized() =>
Stream.MessageReceived += OnMessageAsync;
private Task OnMessageAsync(AgentMessage message) =>
// Hop back onto the renderer's synchronization context before mutating.
InvokeAsync(() =>
{
_messages.Add(message);
StateHasChanged();
});
public ValueTask DisposeAsync()
{
Stream.MessageReceived -= OnMessageAsync;
return ValueTask.CompletedTask;
}
}
Do not connect with ?userid=..., send fromHandle as user-selected identity, or serialize raw AgentMessage. WebSocket v2 authenticates the principal before upgrade and accepts typed operations only.
Circuit reconnects and checkpoints
A Blazor Server circuit can drop and resume without a page reload, and a browser refresh creates a new circuit entirely. Both cases tear down the scoped service and open a fresh socket. Two settings decide what the user sees afterwards:
- The client id determines which backlog is resumed. Derive it from the principal plus a persisted browser value — not from a per-circuit GUID, which would start every reconnect at the tail and silently lose messages sent while the user was away.
- The checkpoint store determines the resume point. The in-memory default is enough while a circuit lives; pass a durable store when deliveries must survive an app restart or a scale-out to another instance.
Keep MaxClientsPerPrincipal in mind if users routinely open several tabs, and add each production browser origin to AllowedWebSocketOrigins on the Host — an unlisted origin is rejected before the upgrade completes.
Follow the complete WebSocket v2 guide for handshake, replay, acknowledgements, and gap recovery.
Migration from Chat UI Components
- Remove ChatDock,
ChatDockManager,ClientContext, andDirectMessageSender. - Move FabrCore calls and principal resolution into a scoped application service.
- Render messages with your existing component system and keep transient UI state outside FabrCore grains.
- Replace
IClientGrainassumptions withprincipalHandle:agentHandletargets. - Use a Surface component when you want FabrCore's Adaptive Card and command-center UI rather than a custom Blazor shell.