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...

Panel Size

Control the size of the expanded chat panel using the ChatDockSize parameter:

ValueDescription
SmallCompact panel for quick interactions
MediumDefault panel size
LargeWider panel for detailed conversations
FullFull viewport width and height
Razor — ChatDock with Size
<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:

Razor — ChatDock with Plugins and Tools
<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" />
Plugin and Tool Parameters

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.

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