MentorAgent.Blazor
1.0.0-rc.12
dotnet add package MentorAgent.Blazor --version 1.0.0-rc.12
NuGet\Install-Package MentorAgent.Blazor -Version 1.0.0-rc.12
<PackageReference Include="MentorAgent.Blazor" Version="1.0.0-rc.12" />
<PackageVersion Include="MentorAgent.Blazor" Version="1.0.0-rc.12" />
<PackageReference Include="MentorAgent.Blazor" />
paket add MentorAgent.Blazor --version 1.0.0-rc.12
#r "nuget: MentorAgent.Blazor, 1.0.0-rc.12"
#:package MentorAgent.Blazor@1.0.0-rc.12
#addin nuget:?package=MentorAgent.Blazor&version=1.0.0-rc.12&prerelease
#tool nuget:?package=MentorAgent.Blazor&version=1.0.0-rc.12&prerelease
MentorAgent.Blazor
Preview Release — MentorAgent is currently in public preview. APIs may change before the stable release.
Blazor WebAssembly client for MentorAgent. Install this in your Blazor WASM / Blazor Auto client project.
Connects to a MentorAgent.Server hub via SignalR and provides the same <ChatWidget /> experience as Blazor Server — same features, same API, no code changes needed when switching between render modes.
All AI processing happens server-side — configured in your server project with AddMentorAgent(). The client sends messages, registers page context and UI actions, and receives streaming events.
Table of Contents
- What MentorAgent can do
- Package Family
- Getting started
- Widget customization
- Page context and UI actions
- Page navigation
- UI Action overloads reference
[MentorPage]parameters (server project)- All
AddMentorAgentBlazor()options - How it works
- Events — IMentorStateService
- Blazor Auto tip
- Requirements
- Related Packages
- License
What MentorAgent can do
All features below are available. Configure them server-side in AddMentorAgent().
| Feature | Description |
|---|---|
| 🤖 Multi-agent orchestration | Coordinator + specialized agents via Handoff Workflow |
| 👥 Group Chat teams | Multiple agents collaborate before acting |
| 🛠️ Tool discovery | C# methods become AI tools via [Description] or [MentorAction] |
| 🎯 UI Actions | AI invokes page-level actions (highlight rows, open modals, pre-fill forms) as individually named tools with typed parameters and async support |
| 📖 Agent Skills | Domain knowledge loaded on demand (load_skill) — progressive disclosure |
| 🧠 Contextual memory | Remembers user preferences across sessions |
| 🗺️ Page navigation | AI navigates to pages decorated with [MentorPage] |
| 📚 RAG | Inject relevant documents from any vector DB into every AI response |
| 🔌 MCP Client | Consume external MCP servers as additional tools |
| 🖥️ MCP Server | Expose [MentorAction] methods to Claude Desktop, VS Code, Cursor |
| 🌐 A2A Consumer | Connect to remote A2A agents in the Handoff workflow |
| 📡 A2A Server | Expose as a federatable A2A agent |
| 🎨 Customizable widget | Themes, colors, position, avatar, bot name |
| 🌍 Multi-language | 10 languages for AI responses and widget UI |
| 🎤 Voice input/output | Browser Speech Recognition + Speech Synthesis — speaks while the answer streams, stops when the user takes the floor, optional hands-free loop |
| 🧭 Onboarding tour | First-run guide generated on the server from its own pages and tools |
| 🃏 Generative UI cards | A server-side tool returns a card and the widget renders it — fields, accent, buttons |
| 🖼️ Multimodal image input | Attach images to a message — upload, clipboard paste, drag & drop or URL |
| 🌐 Hosted tools | The model provider runs web search, a code-interpreter sandbox and file search — configured server-side, results arrive in the stream |
| 🔒 Safety check | AI-based prompt injection detection |
| ⏱️ Rate limiting | Per-user message limit |
| ✅ Confirmation dialogs | Destructive actions ask for approval (HITL) — including MCP tools, in either the MentorAgent or the Agent Framework native flow |
| 🔐 Role-based actions | Actions restricted by ASP.NET Core identity roles |
| 💸 Token & cost optimization | Slim cache-friendly prompt, semantic tool filtering, history compaction, RAG/memory gating |
Package Family
| Package | Install when |
|---|---|
| MentorAgent | Blazor Server app |
| MentorAgent.Server | Server project (Web API / Blazor Auto server) |
| MentorAgent.Blazor ← you are here | Blazor WASM / Blazor Auto client project |
| MentorAgent.Abstractions | Never directly — it arrives with any of the above |
| MentorAgent.Declarative | Optional — Level-2 specialists in YAML, added on the server project |
Getting started
Installation
# Client project
dotnet add package MentorAgent.Blazor --prerelease
# Server project — MentorAgent is included automatically as a transitive dependency
dotnet add package MentorAgent.Server --prerelease
Server project setup
All AI behaviour is configured on the server (the transitive
MentorAgentcore): agents, RAG, memory, skills, MCP/A2A and the token/cost optimizations below. The client only renders the widget.
// Server/Program.cs
builder.Services.AddMentorAgent(options =>
{
options.AppName = "My App";
options.AppDescription = "An order management application";
options.ChatClient = chatClient;
options.ScanAssemblies = [typeof(Program).Assembly];
// All features configured here: agents, RAG, MCP, A2A, memory, skills...
options.UseMemoryContext = true;
options.UseRag = true;
options.McpServerEnabled = true;
options.A2AServerEnabled = true;
options.EnableSkills = true;
options.RateLimitPerUser = 20;
});
builder.Services.AddMentorAgentServer();
app.MapMentorAgentServer(); // /mentor-hub, /mentor/chat + /mentor/approve, /mentor/cancel, /mentor/session, /mentor/tour, /mentor/admin/metrics
app.MapMentorAgentMcp(); // optional
app.MapMentorAgentA2A(); // optional
Token & cost optimization + reliable memory (server project)
These options cut the tokens sent per request and make memory reliable — all on the server. Full details: MentorAgent core README → Token & cost optimization.
builder.Services.AddMentorAgent(options =>
{
// ...ChatClient, ScanAssemblies as above...
// Embedding model — powers semantic tool filtering AND semantic memory relevance.
options.EmbeddingGenerator = new AzureOpenAIClient(endpoint, credential)
.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();
// Send only the tools semantically relevant to the message (requires EmbeddingGenerator).
options.EnableToolFiltering = true;
options.ToolFilterMinScore = 0.35f;
// Compact long conversation history before each call.
options.EnableCompaction = true;
options.CompactionTokenThreshold = 4000;
// Memory: reliable post-turn fact capture (default true) + inject only relevant memories.
options.UseMemoryContext = true;
options.MemoryAutoCapture = true; // default — reliable writer on Path A
options.MemoryRelevanceFiltering = true; // requires EmbeddingGenerator
});
Robustness, observability & cost dashboard (server project)
Also configured on the server — see the MentorAgent core README for full details.
builder.Services.AddMentorAgent(options =>
{
// Middleware hooks
options.OnException = ex => ex.Message.Contains("rate", StringComparison.OrdinalIgnoreCase)
? "The service is busy, please retry shortly." : null;
options.ConfigureChatClientPipeline = b => b.UseLogging();
// Observability (add an OpenTelemetry exporter to the app as usual)
options.EnableObservability = true;
// Dashboard cost pricing (supply your own; none built in)
options.ModelPricing = new Dictionary<string, ModelPrice>(StringComparer.OrdinalIgnoreCase)
{
["gpt-4o"] = new ModelPrice(2.50m, 10.00m), ["gpt-4o-mini"] = new ModelPrice(0.15m, 0.60m),
};
});
The admin dashboard ships as a Blazor component. Drop it on a protected page (you own the authorization). On Blazor Server / Auto it reads IMentorMetrics in-process; on standalone WASM, fetch GET /mentor/admin/metrics from the server and pass the snapshot:
@* Blazor Server / Auto — in-process (the component reads IMentorMetrics itself) *@
@attribute [Authorize(Roles = "Admin")]
@using MentorAgent.Abstractions.Components
<MentorDashboard Currency="$" />
@* Standalone WASM — fetch the snapshot from the server and pass it in *@
@attribute [Authorize(Roles = "Admin")]
@using System.Net.Http.Json
@using MentorAgent.Abstractions.Components
@using MentorAgent.Abstractions.Models
@inject HttpClient Http
<MentorDashboard Snapshot="_snapshot" OnRefresh="LoadAsync" Currency="$" />
@code {
private MentorMetricsSnapshot? _snapshot;
protected override Task OnInitializedAsync() => LoadAsync();
// HttpClient must target the server; the endpoint is gated by options.DashboardRole.
private async Task LoadAsync() =>
_snapshot = await Http.GetFromJsonAsync<MentorMetricsSnapshot>("mentor/admin/metrics");
}
Also configured/available on the server (see the core README for full examples):
- Rich responses — with
options.EnableRichResponses(server, default on) the assistant formats structured data as Markdown; this widget renders the tables & lists automatically — no client wiring, XSS-safe, tables scroll horizontally on narrow screens. - Model routing (
options.StrongChatClient+options.RoutingStrategy:Semantic/Classifier/Cascade/Custom) — cheap↔strong per turn. - Structured outputs — inject
IMentorStructured(GenerateAsync<T>) for typed results / auto-filled forms. - Evaluation — inject
MentorEvaluator(wraps the Agent Framework's nativeagent.EvaluateAsync) in tests to gate CI on token/quality regressions; plugFoundryEvals/MEAI evaluators for quality & safety.
Client project setup
// Client/Program.cs
using MentorAgent.Blazor.Extensions;
builder.Services.AddMentorAgentBlazor(options =>
{
options.HubUrl = "/mentor-hub"; // URL of MentorAgent.Server hub
options.BotName = "My Assistant";
options.Language = MentorLanguage.English;
options.Theme = MentorTheme.Default;
options.PrimaryColor = "#2563eb";
});
⚠️
HubUrl— relative vs absolute.
- Same origin (Blazor Auto hosted, or WASM served by the same ASP.NET Core host): use a relative path —
options.HubUrl = "/mentor-hub".- Different origin (standalone WASM on
:5001connecting to a server on:5169): use the server's absolute URL —options.HubUrl = "http://localhost:5169/mentor-hub"— and configure CORS on the server (see the MentorAgent.Server README → CORS). Without server-side CORS the SignalR handshake is silently blocked by the browser.
// Standalone WASM example — different origin
builder.Services.AddMentorAgentBlazor(options =>
{
options.HubUrl = "http://localhost:5169/mentor-hub"; // absolute — server on a different port
options.BotName = "My Assistant";
});
Authenticating the connection — AccessTokenProvider
If your users sign in, set this. The server resolves the caller from the SignalR
HubCallerContext.User, and a WebSocket handshake cannot carry an Authorization header — so the
token travels the way SignalR expects, and AccessTokenProvider is where you supply it. It is the
WASM equivalent of the JavaScript client's accessTokenFactory.
var tokenStore = new TokenStore(); // your own: holds the signed-in user's token
builder.Services.AddSingleton(tokenStore); // same instance for your login page
builder.Services.AddMentorAgentBlazor(options =>
{
options.HubUrl = "https://api.example.com/mentor-hub";
options.AccessTokenProvider = () => Task.FromResult(tokenStore.Token); // null while signed out
});
⚠️ Without it every user is anonymous to the server, and every
RequiredRolesaction is blocked. What else follows depends on the server'sAnonymousIdentity. With the defaultPerSession, each hub connection is its own anonymous user: nothing is shared between people, but memory and theRateLimitPerUserallowance start over on every reconnect. WithShared, every user is the single key"anonymous": one rate limit for everybody and one memory bucket. WithMemoryAutoCaptureon (the default), one person's name and preferences then reach another person's prompt.
The server must accept the token from the query string. A WebSocket handshake cannot carry a header, so SignalR sends the token as
?access_token=…. With JWT bearer on the server, forward it inOnMessageReceived, or the hub still sees an anonymous caller:// Server Program.cs builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => options.Events = new JwtBearerEvents { OnMessageReceived = ctx => { var token = ctx.Request.Query["access_token"]; if (!string.IsNullOrEmpty(token) && ctx.HttpContext.Request.Path.StartsWithSegments("/mentor-hub")) ctx.Token = token; return Task.CompletedTask; } });
For the hub, the provider is read on connect and on every reconnect, not per message. (The Stop and confirmation requests read it again each time they are sent.) The widget opens the hub connection lazily, when the user sends the first message, and keeps it for the life of the page. A sign-in before that first message is picked up automatically. A user who signs in after it stays on the old, anonymous connection:
MentorHubClienthas no way to restart it, so reload the page after sign-in (Navigation.NavigateTo(Navigation.Uri, forceLoad: true)) and restore the persisted token during startup. A reconnect is a new connection on the server, so it also starts a new conversation.
For anything else the connection needs — a custom header, a transport restriction, cookies —
ConfigureConnection runs on the same HttpConnectionOptions immediately afterwards:
options.ConfigureConnection = o => o.Headers["X-Tenant"] = tenantId;
Stop and confirmations — plain HTTP to the hub's server
■ Stop (POST /mentor/cancel) and the answer to a confirmation (POST /mentor/approve) cannot
go through the hub (see How it works), so the widget sends them as HTTP requests.
They follow the hub settings above:
- Where they go. When
HubUrlis an absolutehttp(s)URL, both are posted to the hub's origin (https://api.example.com/mentor/approveforHubUrl = "https://api.example.com/mentor-hub"). With a relativeHubUrl(same-origin hosting) they stay relative and resolve against the host'sHttpClient.BaseAddress. - What they carry. When
AccessTokenProvideris set, each request sendsAuthorization: Bearer <token>with the token it returns — the same credential the hub uses. Both endpoints accept a Stop or an answer only from the user who opened the hub connection (401for an anonymous caller,403for another user), so this is what lets a signed-in user's Stop and confirmations through.
Two things are still yours:
- Register an
HttpClient. The widget sends these requests through theHttpClientin your client's DI container, andAddMentorAgentBlazor()does not register one. The WebAssembly template'sbuilder.Services.AddScoped(sp => new HttpClient { BaseAddress = ... })is enough; with an absoluteHubUrlitsBaseAddressdoes not matter for these two calls. - CORS, cross-origin. The server's CORS policy must allow the client's origin for these POSTs as
well as for the hub, including the
Authorizationheader when you send a token.
A failure is only logged (the widget has already closed the prompt), so check the browser console if Stop or a confirmation seems to be ignored.
Add the widget
In MainLayout.razor or any page:
@using MentorAgent.Abstractions.Components
<ChatWidget />
Important — Blazor WASM requires manual CSS/JS links in index.html.
Unlike Blazor Server (where the widget injects CSS automatically via <HeadContent>), Blazor WASM uses a static index.html that is served before the .NET runtime starts. Add these two lines to your wwwroot/index.html:
<head>
...
<link href="_content/MentorAgent.Abstractions/css/MentorAgent.css?v=10" rel="stylesheet" />
</head>
<body>
...
<script src="_content/MentorAgent.Abstractions/js/MentorAgent.js?v=10"></script>
</body>
Without this, the widget will render unstyled until after WASM initializes (flash of unstyled content).
⚠️ Keep the
?v=and bump it on every upgrade. On Blazor Server the widget writes these tags itself and versions them for you; here they are yours, nothing fingerprints them, and a returning visitor's browser will reuse the copy it already has. A staleMentorAgent.jsfails silently and selectively — the widget still works, but the calls that did not exist in the older file are swallowed by theirtry/catch, so the onboarding tour never appears and voice falls back to reading the whole answer at the end. If a feature seems missing after an upgrade, check the served file before anything else: it should containflagGetandbeginSpeech.
On Blazor Server, <ChatWidget /> injects its own CSS and JS automatically — no changes to _Host.cshtml or App.razor needed.
Widget customization
Theme and appearance
options.Theme = MentorTheme.Minimal; // Default | Dark | Minimal | Custom
options.PrimaryColor = "#7c3aed"; // any hex color
options.Position = ChatPosition.BottomRight; // BottomRight | BottomLeft | TopRight | TopLeft | SideRight | SideLeft
options.AvatarUrl = "/my-avatar.png";
options.BotName = "ShopFlow Assistant";
Welcome message and input
options.WelcomeMessage = "Hello! How can I help you today?";
options.InputPlaceholder = "Ask anything...";
options.EnableSuggestions = true; // show suggestion chips in the welcome panel
Language (10 supported)
options.Language = MentorLanguage.Italian;
// English | Italian | French | German | Spanish | Portuguese | Dutch | Polish | Japanese | Chinese
This sets the widget's UI strings only. The language the assistant answers in is the server's options.Language in AddMentorAgent(), and the prompt enforces it whatever language the user writes in. Set both:
// Server Program.cs
builder.Services.AddMentorAgent(options => { /* ... */ options.Language = MentorLanguage.Italian; });
// Client Program.cs
builder.Services.AddMentorAgentBlazor(options => { options.Language = MentorLanguage.Italian; });
Voice
options.EnableVoiceInput = true; // microphone button (browser Speech Recognition)
options.EnableVoiceOutput = true; // text-to-speech for AI responses
options.VoiceStreaming = true; // default — speak sentence by sentence while the answer streams
options.VoiceBargeIn = true; // default — taking the floor stops playback
options.VoiceHandsFree = false; // opt-in — keep the conversation going by voice alone
options.VoiceRate = 1.0; // 0.5–2.0
Unlike image input and the hosted-tools badge below, these are not a mirror of server settings. Voice is entirely browser behaviour: the audio never leaves the page, the server sees only the transcript as an ordinary message, and these options are the real thing rather than a copy of something the server enforces.
VoiceStreaming is what makes voice output usable on anything longer than a sentence. Without it the assistant stays silent for the whole response and then recites it. Text is buffered to a sentence boundary and queued as its own utterance, so speech keeps pace with the stream — and the boundary detection knows that 8.459 is one number and that a fenced code block must be dropped whole rather than read out.
VoiceBargeIn stops playback when the user presses the microphone, sends a message, presses ■ Stop or starts a new conversation. It is press-to-interrupt, not acoustic: a browser cannot listen through its own playback without echo cancellation.
VoiceHandsFree sends on silence and reopens the microphone after the spoken answer ends. It needs both voice options on — with nothing to listen to there is nothing to wait for, and the loop would transcribe the assistant. If you set only one, it is ignored.
Onboarding tour
options.EnableOnboardingTour = true; // shown once, on this user's first open of the widget
options.TourUrl = "/mentor/tour"; // default — only change it if you remapped the server endpoints
The steps are generated on the server — only it knows the registered [MentorPage] pages and the assistant's tools — and fetched from GET /mentor/tour. So the server also needs options.EnableOnboardingTour = true; the client flag only decides whether to show it.
That split follows the same rule as the hosted-tools badge: the server is the single source of truth, and a client that reproduces server state locally eventually displays something the server no longer agrees with.
Each step can carry a ready-made question the user sends with one tap — which is the point, since the usual failure of an in-app assistant is not that people cannot find it but that they do not know what to ask. A ? button in the header replays the tour later.
If the fetch fails the widget opens normally and logs a warning: a missing tour is a missing nicety, not a broken chat.
Generative UI cards
Nothing to configure on the client. When a server-side tool returns a MentorCard, the server pushes it on the Cards hub event and the widget renders it under the reply — fields, accent colour and buttons.
Buttons work the same as in Blazor Server: SendMessage sends the text as a user turn, Navigate routes to the URL, UIAction runs an action the current page registered (resolved at click time, so a stale handler from a page you have navigated away from is skipped rather than invoked).
To render a card kind yourself, supply a template and fall back to the built-in renderer for the rest:
<ChatWidget>
<CardTemplate Context="card">
@if (card.Kind == "order") { <OrderCard Card="card" /> }
else { <MentorCardView Card="card" /> }
</CardTemplate>
</ChatWidget>
The fallback's buttons work without any wiring: ChatWidget cascades its own card-action dispatcher around the cards, and a <MentorCardView> inside the template that sets no OnAction uses it, with the same SendMessage / Navigate / UIAction behaviour as above. An explicit OnAction on the MentorCardView still wins. The dispatcher reaches only MentorCardView: buttons you draw yourself in a template (as OrderCard might) get nothing from it, so render a MentorCardView for any card whose buttons should behave like the built-in ones.
Cards are built by server-side application code, not by the model, which is why they can safely carry buttons: nothing said in the conversation can add one or change where it points.
Image input (multimodal)
Lets the user attach images to a message. The widget accepts them four ways — 📎 file picker, Ctrl+V clipboard paste, drag & drop onto the composer, and 🔗 remote URL — and shows removable thumbnails before sending and inside the message bubble afterwards.
// Client Program.cs — mirrors the server settings (the server is what actually enforces them)
builder.Services.AddMentorAgentBlazor(options =>
{
options.EnableImageInput = true;
options.MaxImageBytes = 4 * 1024 * 1024; // per image (default 4 MB)
options.MaxImagesPerMessage = 4; // per turn (default 4)
options.AllowedImageTypes = ["image/png", "image/jpeg", "image/webp"]; // MIME allow-list
});
The server must also enable it, with a vision-capable model:
// Server Program.cs
builder.Services.AddMentorAgent(options =>
{
// ...AppName, ScanAssemblies as above...
options.ChatClient = azure.GetChatClient("gpt-4.1").AsIChatClient(); // vision-capable
options.EnableImageInput = true;
});
The client values only drive the UI and give the user instant feedback; every attachment is re-validated server-side (allow-list, size, count) before it reaches the model. Images travel over the hub as the second argument of SendMessage(text, attachments). The client always sends both arguments, with null for a text-only turn, because SignalR binds hub arguments by count. The images ride inside that one hub message as base64, so AddMentorAgentServer() raises MentorHub's MaximumReceiveMessageSize from the server's MaxImageBytes × MaxImagesPerMessage (+512 KB). Keep the client values at or below the server's: a message larger than the hub limit makes SignalR abort the connection before the server can validate it, and the user sees their own bubble and no reply.
Sending images from your own code:
@inject IMentorOrchestrator Orchestrator
await Orchestrator.SendMessageAsync("Cosa non va in questo screenshot?", [
new MentorAttachment { MimeType = "image/png", DataBase64 = base64, FileName = "error.png" },
new MentorAttachment { MimeType = "image/jpeg", Url = "https://cdn.example.com/product.jpg" },
]);
Image bytes are streamed to .NET through
IJSStreamReference, never marshalled as one big interop payload — so this works unchanged on Blazor Server too, without touchingHubOptions.MaximumReceiveMessageSize.
RAG citations
options.ShowRagSources = true; // show citation chips below AI responses
MCP and A2A status badges
When the server has MCP client servers or remote A2A agents configured, the widget can display live status badges in the header. Because the WASM client doesn't read the server configuration directly, you must mirror the relevant settings:
// Client Program.cs
builder.Services.AddMentorAgentBlazor(options =>
{
// MCP badge — mirror McpServers names from the server
options.ShowMcpStatus = true;
options.HasMcpServers = true;
options.McpServerNames = ["time", "filesystem"]; // must match server McpServers[].Name
// A2A badge — mirror RemoteAgents from the server
options.ShowA2AStatus = true;
options.HasRemoteAgents = true;
options.RemoteAgentDisplays = [
new AgentDisplayInfo { Name = "ShopFlow-B", AgentCardUrl = "http://localhost:5001" }
];
});
The MCP badge updates dynamically — when a server connects or disconnects the hub fires McpServerStatusChanged and the badge turns green/red. The A2A badge is static (shows configured agents, no live status).
Note:
McpServerNamesmust match theNamefields inMentorMcpServer[]configured on the server. A mismatch shows a stale "connecting" badge.
Hosted tools badge
If the server enables hosted tools (provider-side web search, code interpreter, file search, image generation, remote MCP), an amber pill lists them in the header. Do not list them here — turn the badge on and let the server say what it enabled:
options.ShowHostedToolsStatus = true; // that's all
The server sends its active set on the HostedToolsDeclared hub event the moment the client connects, and the widget prefers it over any local value. On WebAssembly the connection opens when the user sends the first message, so until then the badge shows options.HostedTools (nothing, by default). Setting options.HostedTools by hand still works as a pre-connection placeholder, but it is a copy that goes stale: change the server's configuration and the badge starts claiming tools that are not there — which is worse than no badge, because it is believed.
Unlike MCP the badge has no live status afterwards: hosted tools are configuration, not a connection.
Live activity needs no configuration here. When the server has ShowHostedToolActivity on, the widget already shows what the provider is doing, over the hub events it consumes anyway:
- "Ricerca sul web… · .NET 10" in the feedback line, via
ActionExecuting/ActionCompleted - pages cited by web search as citation chips, via
RagSourcesReady— the same panel as RAG, so it also needsShowRagSources - generated images attached to the finished message, via the
GeneratedImagesevent
The WASM client handles all three out of the box.
Page context and UI actions
IMentorPageContext works identically to Blazor Server. Register context data and UI actions in any page — they are sent to the server as a snapshot before each AI message.
Inject page context
@inject IMentorPageContext PageContext
@implements IDisposable
@code {
protected override void OnInitialized()
{
PageContext
.SetPageName("Orders")
.Set("ActiveFilter", "Pending")
.Set("VisibleRows", _orders.Count);
}
public void Dispose() => PageContext.Clear();
}
Register UI actions (no parameter)
PageContext.RegisterUIAction(
"open_create_modal",
"Opens the modal to create a new order",
_ => OpenCreateModal());
Register UI actions with typed parameter
// The AI calls highlight_row(42) — parameter deserialized automatically
PageContext.RegisterUIAction<int>(
"highlight_row",
"Highlights the specified order row",
id => HighlightRow(id),
parameterHint: "integer: order ID");
Async UI actions
PageContext.RegisterUIActionAsync<OrderModel>(
"prefill_form",
"Pre-fills the edit form with order data",
async model => {
_formModel = model;
await InvokeAsync(StateHasChanged);
},
parameterHint: "JSON: { orderId, amount, status }");
Remove an action
PageContext.UnregisterUIAction("highlight_row");
Signal page ready (Blazor Server only)
On WebAssembly SignalReady() is a no-op and costs nothing, so you can keep the call in pages shared with a Blazor Server host:
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
PageContext.SignalReady(); // Blazor Server: tells navigate_to the UI actions are ready. WASM: no-op
}
What matters on WebAssembly is when the server learns about the page. Its name, data and UI actions reach the server only with the page-context snapshot sent before the user's next message (UpdatePageContext). So when the assistant moves the user with navigate_to, the server does not wait for the new page — even on a [MentorPage] with HasUIActions = true, because on a hub connection nothing could ever signal it. The new page's UI actions become callable from the user's next message, not later in the same answer.
(On Blazor Server, where navigate_to does wait on HasUIActions pages, call SignalReady() as the last line of OnInitialized / OnInitializedAsync, not from OnAfterRenderAsync, or the wait may already have timed out.)
Page navigation
⚠️
[MentorPage]attributes are defined in the server project (scanned byScanAssemblies), not in the client project.
// Server project — scanned via options.ScanAssemblies
[MentorPage(Url = "/orders", Name = "Orders", Description = "Order management")]
public class OrdersPage { }
[MentorPage(Url = "/products", Name = "Products", HasUIActions = true, ReadyTimeout = 3000)]
public class ProductsPage { }
The AI calls navigate_to("/orders") — the WASM widget handles navigation automatically via Blazor's NavigationManager.
HasUIActions and ReadyTimeout only affect Blazor Server hosts. For a turn that arrives over the hub, navigate_to returns without waiting for SignalReady (it logs this at Debug level), and the new page's UI actions reach the model with the next page-context snapshot, on the user's next message — see Signal page ready.
UI Action overloads reference
Four overloads are available, from simple to fully typed and async:
| Overload | Parameter | Execution | Use when |
|---|---|---|---|
RegisterUIAction(name, desc, Action<object?>) |
Raw object? |
Synchronous | Simple no-param or legacy code |
RegisterUIAction<TParam>(name, desc, Action<TParam>) |
Auto-deserialized from JSON | Synchronous | Typed param, sync handler |
RegisterUIActionAsync(name, desc, Func<object?, Task>) |
Raw object? |
Async | No-param async actions (e.g. async _ => { await LoadAsync(); }) |
RegisterUIActionAsync<TParam>(name, desc, Func<TParam, Task>) |
Auto-deserialized from JSON | Async | Typed param, async handler (recommended) |
UnregisterUIAction(name) |
— | — | Remove a specific action dynamically |
Automatic parameterHint generation
For typed overloads, parameterHint is auto-generated from the type when omitted:
TParam |
Auto-generated hint |
|---|---|
int, long |
"integer" |
float, double, decimal |
"number" |
bool |
"boolean" |
string |
"string" |
Guid |
"string (GUID)" |
DateTime |
"string (ISO 8601 date)" |
Status (enum) |
"string (Active\|Inactive\|Pending)" |
List<int> |
"array<integer>" |
OrderFormModel (class) |
"{ customerId: integer, productName: string, ... }" |
Override only when extra clarity is needed:
.RegisterUIAction<int>(
"highlight_row", "Highlights an order row",
id => HighlightRow(id),
parameterHint: "integer: order ID") // ← manual override
On WebAssembly the AI does not wait for your handler. The server forwards the call as
UIActionRequestedand reports the action complete as soon as it is sent. The browser then runs the handler and reports nothing back. As a result, an exception in the handler never reaches the model, andOnUIActionCompletedcan fire before an async handler has finished. Make each action self-contained (load what it needs inside the handler) rather than relying on the model to chain actions that depend on the previous one having finished. (On Blazor Server, where the handler runs in-process, async handlers are awaited.)
[MentorPage] parameters (server project)
| Parameter | Required | Description |
|---|---|---|
Url |
✅ | Page URL (e.g. "/orders") |
Name |
✅ | Human-readable page name injected into the system prompt |
Description |
— | Optional feature description |
HasUIActions |
— | If true, navigate_to waits for PageContext.SignalReady() before UI actions — Blazor Server (interactive circuit) only. On a hub turn from this WASM client it does not wait: the page's actions arrive with the user's next message. Default: false |
ReadyTimeout |
— | Timeout in ms for SignalReady() (Blazor Server only). Default: 2000 |
All AddMentorAgentBlazor() options
| Option | Type | Default | Description |
|---|---|---|---|
HubUrl |
string |
"/mentor-hub" |
URL of the MentorAgent.Server SignalR hub. When absolute (http(s)), its origin is also where Stop (/mentor/cancel) and confirmation answers (/mentor/approve) are posted |
AccessTokenProvider |
Func<Task<string?>>? |
null |
Bearer token for the hub, read on connect and on every reconnect, and sent as Authorization: Bearer on every Stop and confirmation request. Without it every user is anonymous: RequiredRoles blocked; memory and rate limit are per connection (server default AnonymousIdentity = PerSession) or one shared bucket (Shared) |
ConfigureConnection |
Action<HttpConnectionOptions>? |
null |
Escape hatch on the hub's connection options — headers, transports, cookies. Runs after AccessTokenProvider |
BotName |
string |
"Mentor AI" |
Bot name in the widget header |
WelcomeMessage |
string? |
null |
Welcome message (HTML supported) |
AvatarUrl |
string? |
null |
Custom avatar URL |
InputPlaceholder |
string? |
null |
Input box placeholder |
Theme |
MentorTheme |
Default |
Widget visual theme |
Position |
ChatPosition |
BottomRight |
Widget position on screen |
PrimaryColor |
string? |
null |
Custom hex accent color |
Language |
MentorLanguage |
English |
Language for widget UI strings (10 languages supported) |
EnableVoiceInput |
bool |
false |
Show microphone button (browser Speech Recognition) |
EnableVoiceOutput |
bool |
false |
Text-to-speech for AI responses (browser Speech Synthesis) |
VoiceStreaming |
bool |
true |
Speak each sentence as it streams instead of reading the finished answer back |
VoiceBargeIn |
bool |
true |
Stop playback when the user takes the floor |
VoiceHandsFree |
bool |
false |
Voice-only conversation loop. Ignored unless both voice options are on |
VoiceRate |
double |
1.0 |
SpeechSynthesisUtterance.rate — useful range 0.5–2.0 |
EnableOnboardingTour |
bool |
false |
Show the guided tour on first open. The server must enable it too |
TourUrl |
string |
"/mentor/tour" |
Where to fetch the tour steps from. A relative value is resolved against HubUrl's origin when HubUrl is absolute (the tour is served by the same server as the hub), otherwise against the app base address. An absolute URL is used as-is |
EnableSuggestions |
bool |
false |
Suggestion chips in the welcome panel |
EnableImageInput |
bool |
false |
Image attachments — 📎 upload, paste, drag & drop and 🔗 URL. Must also be enabled server-side |
MaxImageBytes |
int |
4194304 |
Client-side size cap per image (4 MB). Mirrors the server option |
MaxImagesPerMessage |
int |
4 |
Client-side cap on images per message. Mirrors the server option |
AllowedImageTypes |
List<string> |
png, jpeg, gif, webp |
Client-side MIME allow-list. Mirrors the server option |
ShowRagSources |
bool |
false |
Citation chips below AI responses |
ShowHostedToolsStatus |
bool |
false |
Show the hosted-tools badge (provider-side web search / code interpreter / file search / image generation / remote MCP) |
HostedTools |
MentorHostedTools |
None |
Pre-connection placeholder only. The server publishes its actual set on HostedToolsDeclared at connect and the widget prefers that — leave this unset rather than keeping a copy that goes stale |
ShowMcpStatus |
bool |
false |
Show MCP server connection status badge in the widget header |
HasMcpServers |
bool |
false |
Whether the server has MCP client servers configured (enables the MCP badge) |
McpServerNames |
List<string> |
[] |
Names of MCP servers configured on the server — pre-populates the badge in "connecting" state at startup |
ShowA2AStatus |
bool |
false |
Show A2A remote agent status badge in the widget header |
HasRemoteAgents |
bool |
false |
Whether the server has remote A2A agents configured (enables the A2A badge) |
RemoteAgentDisplays |
List<AgentDisplayInfo> |
[] |
Remote A2A agents to display in the A2A badge and detail bar |
How it works
[Blazor WASM Browser]
ChatWidget
↓ IMentorOrchestrator (WasmMentorOrchestrator)
↓ SignalR
[ASP.NET Core Server — MentorAgent.Server]
MentorHub
↓ MentorOrchestrator (full AI pipeline)
↓ AI Provider (Azure OpenAI, OpenAI, Ollama...)
↑ Streaming events (chunks, confirmations, navigation, UI actions...)
↑ SignalR
ChatWidget renders response
- Page context (page name, data, UI action descriptions) is sent to the server before every message via
UpdatePageContext. - UI action invocations arrive from the server as
UIActionRequestedand are executed locally in the browser byWasmMentorStateService. - Confirmation dialogs (HITL) are shown inline by
ChatWidget. The response is sent viaPOST /mentor/approve?actionId=...&approved=true|false(HTTP) — not via a hub method. SignalR processes hub messages sequentially per connection, so callingRespondToApprovalvia hub whileSendMessageis awaiting would deadlock.WasmMentorStateServicesends it to the hub's origin with the hub's bearer token — see Stop and confirmations. - Stop (cancelling an in-flight turn) is sent via
POST /mentor/cancel?connectionId=...(HTTP) — not via theCancelRequesthub method, for the same sequential-dispatch reason: whileSendMessageis streaming, SignalR cannot dispatch another hub invocation on that connection, so the hub call would only run after the turn it was meant to abort.WasmMentorOrchestratorsends it the same way as the approval: to the hub's origin, with the hub's bearer token. - UI actions after
navigate_to: the server does not wait for the new page (nothing on a hub connection can callSignalReady); the page's actions reach the model with the nextUpdatePageContext, on the user's next message. - Navigation triggered by the AI arrives as
NavigationRequestedand is handled by Blazor'sNavigationManager.
Conversation lifetime and session snapshots
The conversation lives on the server, in a DI scope that belongs to one SignalR connection. A reconnect (network drop, server restart, a laptop waking up) gets a new connection id and therefore a new, empty conversation on the server, even though the widget still shows the old messages.
IMentorOrchestrator.SerializeSessionAsync / RestoreSessionAsync throw NotSupportedException on WebAssembly, because the AgentSession is not in the browser. Save and restore it over HTTP instead, with the live connection id. That id is null until the first message opens the connection.
@inject MentorAgent.Blazor.Hub.MentorHubClient Hub
// BaseAddress = the server, carrying the user's token
@inject HttpClient Http
// Save — 204 means there is no conversation yet: keep what you already have
var save = await Http.GetAsync($"mentor/session?connectionId={Uri.EscapeDataString(Hub.ConnectionId!)}");
if (save.StatusCode == HttpStatusCode.OK)
_saved = await save.Content.ReadAsStringAsync();
// Restore on the new connection (after its first message; an earlier request can get 404)
await Http.PostAsync($"mentor/session?connectionId={Uri.EscapeDataString(Hub.ConnectionId!)}",
new StringContent(_saved, Encoding.UTF8, "application/json"));
Both endpoints check that the caller is the user who opened the connection (401/403 otherwise). A body that is not a MentorAgent snapshot is refused with 400 and leaves the live conversation untouched.
Events — IMentorStateService
ChatWidget handles all events automatically. If you need to subscribe to events directly in your own components, inject IMentorStateService:
@inject IMentorStateService State
@implements IDisposable
protected override void OnInitialized()
{
State.OnStreamingChunk += OnChunk;
State.OnStreamingCompleted += OnCompleted;
State.OnBusyChanged += OnBusy;
State.OnError += OnError;
State.OnActionExecuting += OnActionExecuting;
State.OnActionCompleted += OnActionCompleted;
State.OnActionFailed += OnActionFailed;
State.OnConfirmationRequired += OnConfirmationRequired;
State.OnNavigationRequested += OnNavigation;
State.OnRagSourcesReady += OnRagSources;
State.OnTeamMemberSpeaking += OnTeamSpeaking;
State.OnUIActionExecuting += OnUIActionStart;
State.OnUIActionCompleted += OnUIActionEnd;
}
public void Dispose()
{
State.OnStreamingChunk -= OnChunk;
// ... unsubscribe all
}
Complete event reference
| Event | Signature | Fired when | Typical use |
|---|---|---|---|
OnStreamingChunk |
Action<string> |
Each streaming token | Append text to a custom chat bubble |
OnStreamingCompleted |
Action |
Full response received | Finalize message, re-enable input |
OnBusyChanged |
Action<bool> |
AI starts/stops processing | Show/hide spinner |
OnError |
Action<string> |
Critical error (rate limit, safety block) | Show error banner |
OnActionExecuting |
Action<string> |
Tool/agent is executing | Show action feedback bar |
OnActionCompleted |
Action<string> |
Tool execution succeeded | Hide feedback bar |
OnActionFailed |
Action<string> |
Tool execution failed | Show error in feedback |
OnConfirmationRequired |
Action<ConfirmationRequest> |
Destructive action needs user approval | Show custom confirmation dialog |
OnApprovalResponse |
Action<ConfirmationRequest, bool> |
User confirmed/rejected | Internal — used by orchestrator |
OnNavigationRequested |
Action<string> |
AI triggered navigation | Custom routing logic |
OnRagSourcesReady |
Action<IReadOnlyList<MentorRagResult>> |
RAG documents the finished answer cited (plus hosted web/file-search citations); not raised when nothing was cited | Show custom citation UI |
OnTeamMemberSpeaking |
Action<string, string> |
GroupChat member speaking | Show "Team · Role" feedback |
OnUIActionExecuting |
Action<string> |
UI action started | Custom feedback |
OnUIActionCompleted |
Action<string> |
UI action completed | Custom feedback |
OnMcpServerStatusChanged |
Action<string, bool> |
MCP server connects (true) or disconnects (false) |
Update the MCP status badge — forwarded over the hub as McpServerStatusChanged |
OnCardsReady |
Action<IReadOnlyList<MentorCard>> |
A tool returned generative-UI cards | Render them yourself instead of using CardTemplate |
OnGeneratedImages |
Action<IReadOnlyList<string>> |
The hosted image tool produced images | Show them in your own gallery. Each string is a data: URI or URL |
OnHostedToolsDeclared |
Action<MentorHostedTools> |
Once per connection, right after connect | Drive your own capability badge from what the server actually enabled, rather than from a client-side guess |
@inject IMentorStateService State
@inject IMentorOrchestrator Orchestrator
@inject NavigationManager Nav
@implements IDisposable
@if (_capabilities.HasFlag(MentorHostedTools.WebSearch)) { <span class="badge">🌐 web</span> }
@foreach (var url in _images) { <img src="@url" alt="generata" /> }
@* Outside ChatWidget no dispatcher is cascaded: without OnAction the buttons do nothing *@
@foreach (var c in _cards) { <MentorCardView Card="c" OnAction="OnCardAction" /> }
<span>MCP: @_mcp.Count(x => x.Value)/@_mcp.Count</span>
@code {
private MentorHostedTools _capabilities;
private IReadOnlyList<string> _images = [];
private IReadOnlyList<MentorCard> _cards = [];
private readonly Dictionary<string, bool> _mcp = new();
protected override void OnInitialized()
{
State.OnHostedToolsDeclared += Declared;
State.OnGeneratedImages += Images;
State.OnCardsReady += Cards;
State.OnMcpServerStatusChanged += Mcp;
}
// Fires once per connection, before any message. Render your capability badge from THIS rather
// than from a client-side copy of the server's configuration — the two drift, and a badge
// claiming web search the model never got is worse than no badge.
private void Declared(MentorHostedTools flags) { _capabilities = flags; InvokeAsync(StateHasChanged); }
private void Images(IReadOnlyList<string> urls) { _images = urls; InvokeAsync(StateHasChanged); }
// Only if you are NOT using ChatWidget's CardTemplate. Ignoring cards loses them outright: the
// model is told they are already on screen, so it will not repeat their content as text.
private void Cards(IReadOnlyList<MentorCard> cards) { _cards = cards; InvokeAsync(StateHasChanged); }
private async Task OnCardAction(MentorCardAction action)
{
if (action.Kind == MentorCardActionKind.SendMessage) await Orchestrator.SendMessageAsync(action.Value);
else if (action.Kind == MentorCardActionKind.Navigate) Nav.NavigateTo(action.Value);
// UIAction: run the current page's registered action named action.Value, as ChatWidget does
}
private void Mcp(string name, bool connected) { _mcp[name] = connected; InvokeAsync(StateHasChanged); }
public void Dispose()
{
State.OnHostedToolsDeclared -= Declared;
State.OnGeneratedImages -= Images;
State.OnCardsReady -= Cards;
State.OnMcpServerStatusChanged -= Mcp;
}
}
All eighteen events are listed above — that is the whole of IMentorStateService's read side.
All events fire on a background thread from the SignalR connection. Always use
InvokeAsync(StateHasChanged)when updating Blazor component state from these handlers.
Sending state changes — the Notify* side
Each event has a matching Notify* method on the same interface (NotifyStreamingChunk,
NotifyCardsReady, …). Those are how the orchestrator raises the events; application code
subscribes and does not call them.
The two members you do call are the HITL replies:
| Method | Purpose |
|---|---|
ConfirmAsync(Guid actionId) |
Approve the pending action |
Cancel(Guid actionId) |
Reject it |
actionId is ConfirmationRequest.ActionId from OnConfirmationRequired. In WASM these travel to
the server over POST /mentor/approve (at the hub's origin, with the AccessTokenProvider token —
see Stop and confirmations), never as a hub method — SignalR dispatches one hub call at a
time per connection, so an approval sent through the hub would deadlock behind the turn that is
waiting for it.
Custom HITL confirmation dialog
ChatWidget always shows its own inline confirmation prompt when OnConfirmationRequired fires, and no parameter turns it off. Subscribing therefore adds your dialog next to the widget's prompt instead of replacing it. Answering from your dialog also does not close the widget's prompt, which reacts only to its own buttons. To show only your dialog, hide the built-in prompt with CSS (.bm-confirm { display: none !important; }: the package stylesheet sets display: flex on the same selector), then subscribe to OnConfirmationRequired and call ConfirmAsync / Cancel yourself.
This works identically whichever
MentorOptions.HitlModethe server uses (Blockingor the Agent Framework'sNativeflow) — the WASM client sees the sameConfirmationRequiredevent and replies over the samePOST /mentor/approveendpoint, so switching mode server-side needs no client change.
@inject IMentorStateService State
@implements IDisposable
@if (_pendingConfirmation is not null)
{
<div class="my-confirm-dialog">
<p>@_pendingConfirmation.Message</p>
<button @onclick="Approve">Conferma</button>
<button @onclick="Reject">Annulla</button>
</div>
}
@code {
private ConfirmationRequest? _pendingConfirmation;
protected override void OnInitialized()
{
State.OnConfirmationRequired += OnConfirmationRequired;
State.OnApprovalResponse += OnApprovalResponse;
}
private void OnConfirmationRequired(ConfirmationRequest req)
=> InvokeAsync(() => { _pendingConfirmation = req; StateHasChanged(); });
private void OnApprovalResponse(ConfirmationRequest req, bool approved)
=> InvokeAsync(() => { _pendingConfirmation = null; StateHasChanged(); });
private async Task Approve()
{
if (_pendingConfirmation is null) return;
await State.ConfirmAsync(_pendingConfirmation.ActionId);
}
private void Reject()
{
if (_pendingConfirmation is null) return;
State.Cancel(_pendingConfirmation.ActionId);
}
public void Dispose()
{
State.OnConfirmationRequired -= OnConfirmationRequired;
State.OnApprovalResponse -= OnApprovalResponse;
}
}
ConfirmationRequesthas three properties:ActionId(Guid),ToolName(string), andMessage(string). UseToolNameto customize the dialog copy per action type if needed.
Blazor Auto tip
In a Blazor Auto app, you can keep <ChatWidget /> with @rendermode="InteractiveServer" — it stays server-side with no changes to your existing MentorAgent setup. Use MentorAgent.Blazor only when you need the widget to run fully in WebAssembly.
@* Keep server-side in Blazor Auto — zero changes needed *@
<ChatWidget @rendermode="InteractiveServer" />
Requirements
- .NET 10.0+
- Server project must have
MentorAgent+MentorAgent.Serverinstalled and configured - Browser with WebAssembly support
Related Packages
| Package | Purpose |
|---|---|
| MentorAgent | Blazor Server app — AI orchestration engine |
| MentorAgent.Server | Any ASP.NET Core backend — SignalR hub + SSE + MCP + A2A |
| MentorAgent.Abstractions | Shared UI components (transitive dep — no need to install directly) |
| MentorAgent.Declarative | Optional — define Level-2 specialist agents in YAML instead of C# |
License
MIT — the full text ships in the repository's LICENSE file.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- MentorAgent.Abstractions (>= 1.0.0-rc.12)
- Microsoft.AspNetCore.SignalR.Client (>= 10.0.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-rc.12 | 48 | 9/23/2026 |
| 1.0.0-rc.11 | 60 | 9/23/2026 |
| 1.0.0-rc.10 | 57 | 9/19/2026 |
| 1.0.0-rc.9 | 50 | 9/19/2026 |
| 1.0.0-rc.8 | 62 | 9/18/2026 |
| 1.0.0-rc.7 | 54 | 9/16/2026 |
| 1.0.0-rc.6 | 66 | 9/14/2026 |
| 1.0.0-rc.5 | 63 | 9/13/2026 |
| 1.0.0-rc.4 | 67 | 9/9/2026 |
| 1.0.0-rc.3 | 68 | 9/4/2026 |
| 1.0.0-rc.2 | 81 | 8/24/2026 |
| 1.0.0-rc.1 | 76 | 8/19/2026 |
| 1.0.0-preview.5 | 78 | 8/12/2026 |
| 1.0.0-preview.4 | 77 | 8/4/2026 |
| 1.0.0-preview.3 | 75 | 7/24/2026 |
| 1.0.0-preview.2 | 77 | 6/22/2026 |
| 1.0.0-preview | 87 | 6/22/2026 |
1.0.0-rc.12
Found by re-driving the published rc.11 on the six NuGet sample applications, with a Blazor Server host driven as the A2A peer for the first time on published packages and every remote answer held against the peer's REST data, then by a pre-publication pass of this package on the same six applications. One API addition, opt-in: MentorOptions.ToolFilterMinTools (default 0 - tool selection is unchanged from rc.11).
=== FIXED
- BUG-100 (S2): a confirmation-gated action (RequiresConfirmation or the RequiresApproval predicate) called on an A2A task held the task until the calling agent gave up: both the blocking and the native approval path waited for an answer nobody could give. It is now refused at once, in both modes, and the model is told it has to be done from the application itself. The agent card already left these actions out.
- BUG-098 (S2): an action with NavigateTo ran, then the auto-navigation read NavigationManager.Uri, which throws in a scope with no circuit, and the tool was reported failed - so the model ran it again and the caller was told it had not happened. Seen over A2A on a Blazor Server host, reachable since rc.11 made that work (BUG-083), and possible on any turn served without a circuit on a host that registers a Blazor NavigationManager. A turn from another agent no longer auto-navigates, and a page URL that cannot be read no longer turns a completed action into a failure. An exception from your own OnToolResult hook still reports an action that already ran as failed: keep that hook from throwing.
- BUG-097 (S3): on a Blazor Server host with memory or RateLimitPerUser on, every A2A task logged BUG-072's warning ("The AuthenticationStateProvider threw ... check the provider can be read from a scoped service"), and a role-gated tool the model reached for was logged as an Error with a stack trace and answered "Error during authorization check". The provider is still asked, so a headless host keeps an authenticated peer's identity as in rc.11. When it cannot be read on a task from another agent, the task is anonymous with a Debug line, and a gated tool is refused as for any anonymous caller - the usual "Tool ... blocked" warning, no Error, no stack trace.
=== MITIGATED, NOT FIXED - read this if you use EnableToolFiltering and have actions that change data
- BUG-099 (S1, present since tool filtering existed; the published rc.10 did the same): on hosts with EnableToolFiltering on (off by default), a question about stock set the stock to 0. The filter sends only the actions whose cosine score clears ToolFilterMinScore; for "Quante unita' di iPad Air ci sono in magazzino?" that was one action, the stock UPDATE (0.351), while the actions that read stock ranked just below (0.29-0.32). Handed one write action for a read question, the model called it with a quantity of 0 - over SSE 3 times of 3, over A2A 4 of 4. NEW: ToolFilterMinTools - once one action clears the threshold, send at least this many best-ranked ones, whatever their score. It is a trade-off, measured both ways, so it is off by default: with 3 the stock question read the stock and wrote nothing, but "Quanto ha speso Anna Ferrari?", whose lone match above the line was the right search, got a by-email lookup the floor had added and answered "no such customer" 0 times of 4, against 4 of 4 with the threshold alone. What protects you is [MentorAction(RequiresConfirmation = true)] on every action that changes data: a person is asked, and over A2A the action is refused (BUG-100). The sample applications now gate their stock update.
=== VERIFIED ON THE PUBLISHED rc.11 (no change)
- BUG-083: a Blazor Server host serving A2A tasks, questions put straight to its /a2a - 6 of 6 true (rc.10: every task empty).
- BUG-082: from the React sample, 8 remote-addressed questions, 8 tasks on the peer, none answered by a local specialist.
- BUG-084 (SafetyCheckTimeout with SafetyCheckFailure = Block) on a Blazor Server and a headless host; BUG-086 (a signed-in WebAssembly user's confirmation and Stop reach the hub's origin with the token); BUG-094 (a webSearch entry in a YAML definition removed with a warning).
=== PERFORMANCE
- rc.11 against this package on the six NuGet sample applications' ApiServer, same host and day, nothing else running, six questions in three rounds: median time to first character 8.9 s against 7.2 s, whole reply 12.6 s against 11.2 s, each faster in 9 of 18 paired turns, the same number of model calls, input tokens equal within 0.1 %. rc.10 against rc.11, measured the same way before: 11.8 s against 10.9 s, input tokens +1-2 % on delegated turns (the note rc.11 added to each delegated answer, saying whose answer it is) and under 1 % elsewhere.
=== STILL OPEN
- BUG-099 (S1): mitigated as above; a structural answer (actions declared read-only) is for a later version.
- BUG-074 (S3): MentorshipLevel.Proactive offers actions the application does not have. Measured, recorded, a product-voice decision.
1,733 tests green, build 0 warnings / 0 errors.
1.0.0-rc.11
Found by re-driving F2 (A2A client -> live peer) on the published rc.10 with every answer held against the peer's REST data AND its task log, and by running the Blazor Server sample on a local model (Ollama). BUG-081 is closed on the published packages. API additions: SafetyCheckTimeout and SafetyCheckFailure; auditing the five READMEs against the code then found eleven code defects behind the text, all fixed below.
=== FIXED
- BUG-082 (S2): a LOCAL specialist's answer reached the user as the REMOTE agent's. "Chiedi a ShopFlowRemote: quanto ha speso Anna Ferrari?" came back in 8.8 s with no task on the peer, reading "... EUR 5.800,00 sull'istanza ShopFlowRemote": the coordinator had called route_to_specialist with the addressee tidied out of the request and no specialist, the router gave a question about a customer to the local CustomerAgent, and the tool returned that agent's words with nothing to say whose they were. The figure matched only because the samples share their seed data. On a host that has remote agents every delegated answer now opens with its source: a local specialist answered -> a note that no remote agent was contacted and an instruction to call once more with specialist set if the user meant one (the second call carries the name, so the retry is bounded); the remote agent answered -> "that system's data, not this application's"; the remote agent was named and never took part -> "NOT X'S ANSWER", plus a warning for the operator. "Remote" means every configured peer, including one whose card could not be fetched when the session was built. The router's list marks remote entries, and its rules say that a request addressed to a remote agent is about that system's data whatever its subject. Hosts without remote agents, and turns that arrived over A2A, read byte for byte what they read before.
- BUG-083 (S2, present in rc.10): a Blazor Server host could not answer another agent - every A2A task it received completed with an empty message. A turn served over A2A has no circuit; AppContextProvider read NavigationManager.Uri unguarded, and it threw "'RemoteNavigationManager' has not been initialized". The caller saw an empty reply and told its user the remote agent was unavailable. The read is guarded, and the A2A handler now FAILS a task that produced no words instead of completing it empty. Headless hosts (MentorAgent.Server) were not affected.
- BUG-084 (S2): the safety checks had a fixed 15-second limit and failed open, so on a slow model (a local one on a CPU) the input check was skipped on every message, with a stack trace; Stop could not interrupt the input check; and a custom InputGuardrail that honoured its cancellation token threw out of SendMessageAsync, leaving the widget busy for good. NEW: SafetyCheckTimeout (default 15 s; zero or negative = no limit, and Stop still cancels) and SafetyCheckFailure (Allow - the default and the previous behaviour - or Block: refuse the message with a localized "can't check it right now, try again", or withhold the reply). Both apply to the input and the output check, built-in or custom. A timeout logs one line that says what to change; an error keeps a single stack trace per turn (BUG-071). The output check now runs on ClassifierChatClient, like the input check. Nothing changes for a host that sets neither option.
=== FIXED - found by auditing the five READMEs against the code (285 findings confirmed by a second reader: 264 were documentation, the rest code)
- WebAssembly client: answering a confirmation and Stop now go to the hub's origin with the hub's bearer token (AccessTokenProvider). They used the host's HttpClient with a relative URL and no token, so a signed-in user's confirmations and Stop were answered 401 by the owner check.
- ConfigureChatClientPipeline is built with the host's services: the documented b => b.UseLogging() made every coordinator build fail.
- ChatWidget CardTemplate: a MentorCardView inside a template reaches the widget's action dispatcher; its buttons did nothing.
- UI actions registered by a hub client (WebAssembly, React, MAUI) keep their parameter hint, and the WebAssembly page context generates the same hints as Blazor Server.
- navigate_to no longer waits for SignalReady where no page can send it (hub, SSE and A2A turns): each navigation to a page with HasUIActions cost the full ReadyTimeout and a warning.
- McpServerPath and A2AServerPath are the default paths of MapMentorAgentMcp() / MapMentorAgentA2A(); nothing read them.
- [MentorAction(ProactiveHint)] reaches the model, appended to the tool description as "Guidance: ..." (not used by semantic tool filtering); it was stored and never read.
- SkillsRefreshInterval caches the composed skill sources once per process; each session built its own cache, so the option did nothing.
- MentorAgent.Declarative: webSearch, codeInterpreter, fileSearch and mcp entries in a YAML definition are removed with a warning; only kind: function is kept. They created provider-hosted tools outside the gate, against the package's promise that a definition file cannot add a capability.
- ChatInput composed without ChatWidget no longer throws on the first message (an unguarded JS call ended the Blazor Server circuit). The output safety check is metered under ClassifierChatClient's model id.
- Documentation: 264 corrections across the five READMEs, among them attribute examples that did not compile, SignalR enums arriving as numbers, the anonymous-identity and rate-limit text (per-session since rc.5), how to protect and what to expect from /mcp and /a2a. XML docs and the server's startup warning match the PerSession default.
=== VERIFIED ON THE PUBLISHED rc.10 (no change)
- BUG-081: F2 on all five package columns - 38 remote-addressed turns, 36 delegated, 36 true figures, none invented, none bounced; "quanti clienti Premium" put straight to the published ApiServer's /a2a endpoint: 10 of 10 (rc.9: 3, 4), and "ordini Pending" 5 of 5.
- BUG-080: one task per turn on the peer, none bounced back, with ApiServer and React up together.
=== STILL OPEN, UNCHANGED
- BUG-074 (S3): MentorshipLevel.Proactive offers actions the application does not have. Measured, recorded, a product-voice decision.
1,721 tests green, build 0 warnings / 0 errors.
1.0.0-rc.10
Found by re-driving the release matrix's open cells on the published rc.9. BUG-080 holds: in the topology that looped (two hosts, each the other's remote agent) a delegated request is one task on the peer and none bounced back, and A2A context is per scope on every caller. With remote answers finally arriving, they could be compared with the data - and several were not true. No API change.
=== FIXED
- BUG-081 (S2): a turn SERVED over A2A stated figures no tool had returned. "Quanti clienti Premium ha?" asked through a caller came back as 18; put straight to the peer's /a2a endpoint, as 3, 4, 11, 16, 6, 6, 8 - there are 2. Tools were offered every time and none was called, while the same server on the same question over SSE called search_customers and said 2. The only difference was the line rc.8 added to an A2A turn's context - "carry the request out with your tools and reply with the result itself" - obeyed in the wrong order: a result at once. (BUG-079's "87 customers" was very likely this.) The notice is now a procedure: FIRST call the tool; every number, name, date or status comes from a tool result of THIS turn; if no tool has it, reply only that the application cannot provide it. A rule in a prompt lowers a rate and does not remove a behaviour - measured live, the notice alone gave 3 grounded answers in 5 - so there is a structural backstop, each step of it added because the one before was measured and was not enough. When an A2A-served turn ends without the coordinator reaching for ANY tool, the handler discards the reply and puts the same request once more, with the turn's context saying why and with a tool call REQUIRED on that attempt's first model call. If the second attempt too is tool-less, the classifier model is asked whether the reply states a value of the application's live records (a model, not a pattern: "9 clienti Premium" and "reso entro 30 giorni" both contain a number); on anything but a clear no the caller receives "NOT GROUNDED: ... do not present a figure" instead of the value. Two attempts, never three; the extra turn is paid only by tool-less A2A requests; a tool-less reply that states no data is returned as it is. Measured on the package-built ApiServer, the hardest host: 15 of 15 correct, none invented, none withheld (rc.9: 18, 3, 4 for a true 2).
- BUG-081, second half: the caller's own name for the peer travels inside the request ("quanti prodotti ha ShopFlowRemote?") and nothing told the peer that name means ITSELF - it answered "I have no access to ShopFlowRemote" 3 times in 6, without calling a tool. The notice now says so, and a MentorAgent caller sends its name for the peer as A2A message metadata (mentoragent.addressedAs), which the peer reads into the notice. The value arrives from another machine and goes into a prompt: only one token of letters, digits and - _ . (64 characters at most) is accepted; anything else is dropped whole.
=== VERIFIED ON THE PUBLISHED rc.9 (no change)
- BUG-080: API and React up together, remote turns driven from every column - one "Task received" per turn on the peer, each followed by "This turn arrived over A2A: remote agent(s) ... are not offered to it", zero tasks bounced. A2A context per scope closed on the React client and the WebAssembly client (one context across a connection's turns, a different one for a second connection).
- BUG-073, residual: the cold first turn of a fresh Chat Completions process delegated this time (route_to_specialist -> OrderAgent, real data).
=== STILL OPEN, UNCHANGED
- BUG-074 (S3): MentorshipLevel.Proactive offers actions the application does not have. Measured, recorded, a product-voice decision.
1,667 tests green, build 0 warnings / 0 errors.
1.0.0-rc.9
Found by re-driving the release matrix's open cells on the published rc.8 - the first published build on which an A2A round trip completes (BUG-078 had kept every one from finishing). T6/T7 closed on every host: route_to_specialist reaches OrderAgent and the declarative ShippingAgent, with real data, and the router's call is metered. The first remote delegation ever driven between the PUBLISHED samples found what was behind it. No API change.
=== FIXED
- BUG-080 (S2): a request delegated over A2A was delegated ONWARD by the peer, and two hosts that peer each other never stopped. Live: "Delega a ShopFlowRemote: quanti prodotti a catalogo?" on one sample produced five tasks on its peer and four on the peer's peer (each ~5,500 input tokens), 141 seconds, no answer - until a server was stopped by hand. Two causes. (1) rc.8 made route_to_specialist prefix the workflow's input with "[Specialist requested: NAME]" for the LOCAL router (BUG-076), and the A2A client sent the last user message verbatim: the marker crossed the wire and the peer's coordinator read it as its own order - every sample calls its remote agent "ShopFlowRemote". The marker now has one writer and one remover (SpecialistMarker), and the A2A client strips it on both the streaming and the non-streaming path. (2) Structural: a turn that ARRIVED over A2A was offered the host's remote agents like any other. It no longer is - they are not built for that scope and are absent from the coordinator's prompt, the router's prompt and the description of route_to_specialist; one Debug line names what was withheld. The host's own specialists, declarative agents, teams and tools still serve the request. Consequence, documented in the README: chaining A -> B -> C through a MentorAgent host is not supported in 1.0 (before rc.8 no chain could complete a single hop). Verified live on the fixed source in the mirror topology that looped (two hosts, each the other's "ShopFlowRemote"): one task per turn on the peer, zero bounced back, "8 prodotti a catalogo" in 32 s.
=== VERIFIED ON THE PUBLISHED rc.8 (no change)
- BUG-076/077: three hosts' logs show route_to_specialist returned: main_coordinator -> OrderAgent[FunctionCall] -> OrderAgent[FunctionResult] -> OrderAgent[Text]; the router's call costs ~400 input tokens and is metered.
- BUG-078: both peers' cards advertise JSONRPC at an absolute URL; tasks are received and logged with their context ids. A2A context per scope holds: one context across a circuit's turns, a different one for a second circuit (Blazor Server and MAUI callers).
- BUG-075, second pass: "Ricorda che il mio codice privato e' ..." and asking for it back are both SAFE / IN scope; no UNSAFE verdict anywhere in the process log.
- BUG-079: with the peer unreachable or looping, the user read "il sistema remoto non ha fornito il numero" - no invented figure.
- BUG-073, residual rate: on the Chat Completions host the cold first turn of a process narrated a handoff without calling the tool, once in five. The fix lowers a rate; it does not remove a behaviour.
=== SAMPLES (not part of the packages)
- MentorAgentServer's remote agent can be renamed and re-pointed from the command line (--A2ARemote:Name / --A2ARemote:Url), so the source pair can mirror the published topology (two hosts, each the other's remote, same name). The source pair that verified rc.8 could not show BUG-080 because it did not.
=== STILL OPEN, UNCHANGED
- BUG-074 (S3): MentorshipLevel.Proactive offers actions the application does not have. Measured, recorded, a product-voice decision.
1,654 tests green, build 0 warnings / 0 errors.
1.0.0-rc.8 (condensed: BUG-075 to BUG-079)
The first delegated turns ever driven live, on the published rc.7. One additive API change: route_to_specialist takes an optional `specialist` argument.
=== FIXED - BUG-078 (S2): the A2A server had never completed a round trip - the card advertised the wrong protocol binding, the handler never submitted the task and disposed its scope the wrong way, and MentorAgent's own A2A client read only Message events. BUG-076 (S2): the handoff router was built from the coordinator's whole prompt (about 5,000 input tokens paid again on every delegated turn) and narrated instead of routing; it now has a router's prompt, the tool returns what the specialists said, and "NOT DELEGATED" / "DELEGATION FAILED" say what happened. BUG-077 (S3): every model call under the coordinator (router, specialists, team members, source-built agents) was unmetered; they now run on the host's ChatClient inside the metering wrapper, and an empty model id falls back to the client's own deployment. BUG-079 (S3): generated specialist and team-member prompts carry a grounding rule (state only what a tool returned); a custom [MentorAgent(Instructions = ...)] is used exactly as written and must carry its own. BUG-075, second pass: on hosts with memory on, the safety classifier is told that what users say about themselves is theirs, not a system secret (verified on the two reported messages); the model itself may still decline to store something its user calls private.
=== CHANGED - A turn that arrives over A2A is told that no human is present.
1.0.0-rc.7
Found by the release matrix: the same ~70 checks driven on all eight sample applications against the published rc.6 packages, signed in and anonymous, on Blazor Server (cookie), WebAssembly and React (JWT), Blazor Auto, .NET MAUI (over CDP) and the two source-referenced apps. 584 cells, every option of MentorOptions and MentorAgentBlazorOptions with a verdict. No API change.
=== FIXED
- BUG-073 (S2): the coordinator was told it COULD delegate to its specialists and never told how - the tool name route_to_specialist appeared nowhere in the prompt and the tool's own description named no agent. Told a specialist existed (BUG-069), the model narrated the handoff ("inoltro subito la domanda allo ShippingAgent, attendo la sua risposta") and ended the turn without calling anything, three times, once under an explicit order. The capabilities block now says how to delegate and forbids announcing an unperformed handoff; the tool's description names every specialist it reaches, attribute-declared, source-supplied and remote.
- BUG-075 (S3): "Ricorda che il mio codice privato e' ZULU-2200" was classified UNSAFE on a host with memory on. Remembering a fact the user asks to keep is a configured capability and is now named to the classifier, the way MCP servers and image input already were (BUG-067).
- BUG-072 (S3): a signed-in user whose AuthenticationStateProvider threw was silently keyed as anonymous - and since rc.5, as a different anonymous in every circuit, so the same user would have had a different memory in every tab with nothing in the log. The fallback stays (fail closed); it is now said once per session, without a stack trace per visitor. The live check on a real cookie principal shows the catch does not fire.
- BUG-068 addendum: one collaborating builder was still announcing per circuit at Information ("Remote agent 'X' connected from ..."). Routed through the same quiet logger; the general test now includes a remote agent.
=== SAMPLES (not part of the packages, recorded for whoever reads them)
- The three samples with authentication expose GET /account/dev-login?as=admin|manager|user in Development only (404 in Production, verified), so the authenticated rows of the matrix can be driven by a script; the WebAssembly and React clients accept ?as= on their login route for the same reason.
- The Blazor Server sample binds RefuseOutOfScope, WarmUpAtStartup, CompactionMaxTurns and RateLimitPerUser from configuration, so command-line overrides actually reach them.
- The samples point their A2A peer at a live server (the headless sample) instead of a port nobody listened on.
=== MEASURED, NOT CHANGED
- BUG-074 (S3, open): MentorshipLevel.Proactive still offers actions the application does not have - 3 in 5 turns ("esportare l'elenco", "cercare con il nome"). The rule is in the prompt; a structural fix (offers must name a listed tool or page) is a product-voice decision, recorded rather than taken.
- A compaction pass writes no log line; it is pinned in-process (CompactionTests). A Debug line when a pass runs would make it observable live.
1,623 tests green, build 0 warnings / 0 errors.
1.0.0-rc.6
Four defects found by running the published rc.5 packages against the sample applications - the pass rc.5's own notes implied but had not yet been done. No API change: every fix restores behaviour rc.5 already claimed.
=== FIXED
- BUG-068 (S3): the configuration summary was NOT written once per process, as rc.5's notes said it was. A second browser circuit still reprinted seven Information lines - the skills catalogue, the external agents, the hosted image model, the Azure image-header note, the hosted MCP server, the hosted tool list and the declarative handoff. The sentinel was claimed halfway through the build, after everything above it had already announced itself, and three collaborating builders never consulted it at all. It is now claimed first, and a later build demotes Information to Debug instead of discarding it, so an operator who turns Debug on to investigate one circuit can still see what it was built with. Degradation warnings stay per session, unchanged.
- BUG-069 (S2): an agent supplied by an IMentorAgentSource - a declarative YAML specialist, or a host's own - joined the handoff graph and was never named in the coordinator's instructions. route_to_specialist names no agent either, so nothing the model could see said the specialist existed: asked about it, the assistant answered that there is no such agent, while the log recorded it being added to the workflow. Source-supplied agents are now listed with their descriptions beside the attribute-declared ones.
- BUG-070 (S4): every skill on the published A2A card carried a flattened name ("Getallordersforanalysis"). The friendly-name helper splits on underscores and was being handed the PascalCase method name. That card is the one artefact whose entire audience is another machine's directory.
- BUG-071 (S4): a turn whose input safety check met the first failure printed two stack traces instead of one - the turn's fault log was reset after that check, so its cause was recorded and immediately discarded. The reset now happens before every early return.
1,616 tests green, build 0 warnings / 0 errors.
1.0.0-rc.5 (condensed; the full account is in the repository's BUGS.md, BUG-062 to BUG-067)
Latency and cost per visitor: time to the first character fell from 2.6-3.0 s to 1.6-2.0 s on a live host - one embedding per text instead of one per consumer, one classifier call for safety and scope, the composer handed back before the post-turn fact extraction, shared MCP sessions and a process-wide agent-card cache.
=== NEW - ClassifierChatClient (a small, fast deployment for MentorAgent's own one-word decisions); AnonymousIdentity (PerSession by default - BREAKING for single-user hosts that want one shared memory: set Shared); RefuseOutOfScope; WarmUpAtStartup; MentorMcpServer.Shared; a configure callback on MapMentorAgentMcp / MapMentorAgentA2A to protect them; IMentorQueryEmbedding.
=== FIXED - BUG-064 (S1): two anonymous visitors shared one memory. BUG-062, 063, 065, 066, 067 (S3/S4): text glued across a tool call, the Native-HITL banner shown before the role check, a spurious RAG scale warning, an unmetered scope classifier, legitimate requests refused by the safety classifier.
=== CHANGED - RAG chips show only cited documents; Proactive mentorship asks for one sentence before acting; the A2A card publishes skills, modes and streaming; the configuration summary is logged once per process.
1.0.0-rc.4
No change in this package. Version aligned with MentorAgent 1.0.0-rc.4, which fixes one S3 in the provider error classifier - see that package's notes. The five packages ship as a set and are meant to be upgraded together.
1.0.0-rc.3
One S1 fix, and it is the reason to upgrade: before this release a WebAssembly client could not authenticate its hub connection at all.
=== FIXED - the widget could not carry credentials (S1) ====================
- MentorHubClient built its connection with WithUrl(options.HubUrl) and nothing else. There was no AccessTokenProvider, no header hook, no overload of AddMentorAgentBlazor that accepts a connection, and the class is sealed - so whatever your application had signed the user in as, the server's identity bridge read HubCallerContext.User and found nothing.
- Three consequences, and the third is the serious one. Every RequiredRoles action was blocked. RateLimitPerUser became one global limit. And every user of the deployment shared the single memory key "anonymous" - with MemoryAutoCapture on by default, personal facts extracted from one person's conversation went into the next person's prompt.
- NEW - options.AccessTokenProvider supplies the bearer token, read on every connect and reconnect. This is the WebAssembly equivalent of the JavaScript client's accessTokenFactory. Return null while nobody is signed in; the connection then behaves exactly as before.
- NEW - options.ConfigureConnection is the escape hatch onto HttpConnectionOptions for anything the named options do not cover: a custom header, a transport restriction, cookies, UseDefaultCredentials.
- Note the timing. The hub connection is a singleton opened on first widget render, and SignalR reads the provider on connect and reconnect only - so a sign-in that happens afterwards takes effect on the next reload or reconnect. Restore a persisted token before the host runs, as the README now shows.
- Why it survived every review: the server README's authentication example shows the JavaScript client, which builds its own connection, so React, Angular and Vue all worked. The only client that could not authenticate was the one this package ships.
- Verified live, both directions: an admin JWT over the hub logged "Memory saved for 'anonymous'" and was refused a role-gated report before the fix; after it, the same token logged the user's real identifier and the report was generated.