Novior.MentorAgent.Server 1.2.0

Prefix Reserved
dotnet add package Novior.MentorAgent.Server --version 1.2.0
                    
NuGet\Install-Package Novior.MentorAgent.Server -Version 1.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Novior.MentorAgent.Server" Version="1.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Novior.MentorAgent.Server" Version="1.2.0" />
                    
Directory.Packages.props
<PackageReference Include="Novior.MentorAgent.Server" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Novior.MentorAgent.Server --version 1.2.0
                    
#r "nuget: Novior.MentorAgent.Server, 1.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Novior.MentorAgent.Server@1.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Novior.MentorAgent.Server&version=1.2.0
                    
Install as a Cake Addin
#tool nuget:?package=Novior.MentorAgent.Server&version=1.2.0
                    
Install as a Cake Tool

Novior.MentorAgent.Server

New package IDs. From 1.0.0 the packages, their namespaces and their assemblies are Novior.MentorAgent.*; up to 1.0.0-rc.15 they were MentorAgent.*. See Migrating from the MentorAgent.* packages in the Novior.MentorAgent README.

Expose a fully-featured AI assistant backend from any ASP.NET Core application — Web API, Minimal API, Blazor Server. No Blazor required on the consumer side.

Novior.MentorAgent.Server adds a SignalR hub, an SSE streaming endpoint, the MCP server and the A2A agent — making your AI backend reachable from any ASP.NET Core application and any client: React, Vue, Angular, MAUI, mobile apps, or any HTTP/SignalR consumer. It is also the required server-side component when pairing with Novior.MentorAgent.Blazor for Blazor WebAssembly and Blazor Auto deployments.


Table of Contents


Package Family

Package Install when
Novior.MentorAgent Blazor Server app
Novior.MentorAgent.Server ← you are here Web API / headless backend, or Blazor Auto server-side project — and any app, Blazor Server included, that publishes the MCP server or the A2A agent
Novior.MentorAgent.Blazor Blazor WASM / Blazor Auto client project

What Novior.MentorAgent.Server exposes

Endpoint Protocol Clients
/mentor-hub SignalR Blazor WASM, MAUI, console .NET, any SignalR client
/mentor/chat SSE streaming React, Vue, Angular, fetch API, curl — no library needed. GET for text, POST for text + images
/mentor/approve HTTP POST All clients — HITL confirmation/rejection (see HITL note)
/mentor/cancel HTTP POST All SignalR clients — stops the in-flight turn (?connectionId=...). HTTP, not a hub method: SignalR cannot dispatch one while SendMessage is streaming. Returns 200 when the connection exists, even if no turn is running, and 404 when the connection is closed or the id is unknown: the connection's turn stopped when it closed, so reconnect before sending again
/mentor/tour HTTP GET All clients — onboarding tour steps as JSON; ?language=it for the tour in that language
/mentor/session HTTP GET / POST All SignalR clients: saves and restores the connection's conversation (?connectionId=...). GET returns the snapshot — opaque, protected with ASP.NET Core Data Protection and bound to the connection's user (204 when there is nothing to save yet). POST with that snapshot as JSON body restores it (400 if it is not one this server issued for this user, was edited, cannot be used by this host, or does not parse; 415 unless the request's content type is JSON). Owner-checked like approve/cancel, before the body is read. A 404 right after connecting means the connection's scope is not registered yet: restore after the first hub call, or retry once
/mentor/admin/metrics HTTP GET Operator dashboards — token & cost snapshot, role-gated. Never expose to end users
/mcp MCP server Claude Desktop, VS Code Copilot, Cursor, any MCP client
/.well-known/agent-card.json + /a2a A2A agent Other AI agents, orchestrators

Behind a load balancer every request of a client — the hub connection and its /mentor/approve, /mentor/cancel and /mentor/session — must reach the same instance: see Running more than one instance in the MentorAgent README.

A reconnect is a new connection and therefore a new conversation. Save while the connection is alive and restore on the new one:

// Owner-checked: send the credentials the hub connection uses. Cookies: credentials: 'include'.
// A bearer token: an Authorization header, see "Calling approve, cancel and session from an
// authenticated client".

// save (e.g. on every StreamingCompleted, or before navigating away)
const res = await fetch(`/mentor/session?connectionId=${connection.connectionId}`, { credentials: 'include' });
if (res.status === 200) localStorage.setItem('mentor-session', await res.text());   // 204 = nothing to save

// restore, after withAutomaticReconnect() or a page reload
async function restore() {
    const saved = localStorage.getItem('mentor-session');
    if (!saved) return;
    const post = () => fetch(`/mentor/session?connectionId=${connection.connectionId}`, {
        method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: saved,
    });
    let r = await post();
    if (r.status === 404) { await new Promise(ok => setTimeout(ok, 250)); r = await post(); }  // scope not registered yet: retry once
}
connection.onreconnected(restore);

Getting started

Installation

dotnet add package Novior.MentorAgent.Server

Novior.MentorAgent is included automatically as a transitive dependency — you do not need to install it separately. The AI provider's SDK is not: for the Azure OpenAI example below add Azure.AI.OpenAI (--prerelease) and Microsoft.Extensions.AI.OpenAI (AsIChatClient()), and Azure.Identity for DefaultAzureCredential. Up to 1.0.0-rc.14 they came with MentorAgent.

Minimal setup (Web API)

// Program.cs — Web API or Minimal API
using Azure.AI.OpenAI;                   // AzureOpenAIClient
using Microsoft.Extensions.AI;           // AsIChatClient()
using Novior.MentorAgent.Abstractions.Models;   // MentorLanguage
using Novior.MentorAgent.Extensions;
using Novior.MentorAgent.Server.Extensions;

builder.Services.AddMentorAgent(options =>
{
    options.AppName        = "My App";
    options.AppDescription = "An order management application";
    options.Language       = MentorLanguage.English;
    options.ChatClient     = new AzureOpenAIClient(endpoint, credential)
                                 .GetChatClient("gpt-4o").AsIChatClient();
    options.ScanAssemblies = [typeof(Program).Assembly];
});
builder.Services.AddMentorAgentServer();   // ← SignalR hub

var app = builder.Build();
app.MapMentorAgentServer();   // exposes /mentor-hub + /mentor/chat
app.Run();

Blazor Auto — server project

// Server project Program.cs
builder.Services.AddMentorAgent(options => { ... });
builder.Services.AddMentorAgentServer();

app.MapMentorAgentServer();
app.MapMentorAgentMcp();   // optional: requires options.McpServerEnabled = true, otherwise it throws at startup (AddMentorAgentServer() registers the server)
app.MapMentorAgentA2A();   // optional: requires options.A2AServerEnabled = true, otherwise it throws at startup (AddMentorAgentServer() registers the agent)

AddRazorComponents() and AddMentorAgentServer() can come in either order: Blazor's circuits keep their user, and the hub, /mentor/chat and A2A get theirs from the request. AddMentorAgentServer() goes after AddMentorAgent(), though: it registers the MCP server and the A2A agent from the options AddMentorAgent() read, and called before it with McpServerEnabled or A2AServerEnabled on, the host stops at start-up with the reason.


CORS — required for cross-origin clients

⚠️ This is the #1 cause of SignalR connection failures. If your client runs on a different origin than the server — a standalone Blazor WASM app on :5001, a React dev server on :5173, an Angular app on :4200, etc. — you must configure CORS on the server. Without it, the browser silently blocks the SignalR handshake.

SignalR with browser clients requires credentials, and the CORS spec forbids AllowAnyOrigin() together with AllowCredentials(). You must list every client origin explicitly:

builder.Services.AddCors(options =>
{
    options.AddPolicy("MentorAgentClients", policy =>
    {
        policy
            .WithOrigins(
                "http://localhost:5001",   // Blazor WASM (HTTP)
                "https://localhost:7001",  // Blazor WASM (HTTPS)
                "http://localhost:5173",   // React (Vite)
                "http://localhost:4200")   // Angular
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();           // ← required for SignalR
    });
});

var app = builder.Build();

app.UseCors("MentorAgentClients");   // 1. CORS first
app.UseAuthentication();             // 2. then who is calling
app.UseAuthorization();              // 3. then what they may do
app.MapMentorAgentServer();          // 4. the endpoints last

Keep this order: UseCors → UseAuthentication → UseAuthorization → Map…. It matters as soon as the hub requires a signed-in user (configureTransports: e => e.RequireAuthorization(), see Protecting the endpoints). A Program.cs that does not call UseAuthentication() and UseAuthorization() itself still gets them, because ASP.NET Core adds them at the start of the pipeline, ahead of your UseCors. The browser's preflight for /mentor-hub/negotiate carries no token, so authorization answers it 401 before CORS can add its headers, and a client on another origin never connects, even with a valid token. With an explicit UseRouting(), UseCors goes after it.

You do NOT need CORS when:

  • The client is served from the same origin as the server (e.g. Blazor Auto hosted, or the WASM app served by the same ASP.NET Core host). In that case relative URLs like HubUrl = "/mentor-hub" work with no CORS at all.

When CORS is required, the client must use the server's absolute URL:

// Client (separate origin) — full URL, not a relative path
options.HubUrl = "http://localhost:5169/mentor-hub";

Connecting clients

Option A — SSE (simplest, no library required)

No library required. Six frame types: chunk and completed carry the answer, error reports a failure, and cards, navigation and confirmationRequired carry the outcomes that are not text.

Every frame beyond the first three is additive — a client that does not recognise a type ignores it and still receives the whole answer — so the three-frame reader below remains correct code. Read the extra frames when you want cards, routing, or approvals; skip them and nothing breaks except those features.

Each /mentor/chat request is its own conversation. The endpoint runs the turn in the request's own DI scope, so every GET/POST starts a fresh session. The model does not see the previous turns, a follow-up such as "and the second one?" has nothing to refer to, and per-session counters (MaxHostedToolCallsPerSession) reset on every call. SSE also has no way to send a PageContextSnapshot, so page context and client-side UI actions are not available. For a multi-turn chat, use SignalR, whose scope and conversation span the connection.

The turn's language. GET /mentor/chat?message=…&language=it, or "language": "it" in the POST body, sets the language of this turn: the name or the ISO code of a MentorLanguage. Anything else, or none, is the application's Language.

Frame Payload What it is for
chunk text, aiGenerated One streamed fragment of the answer. aiGenerated is always true: the machine-readable marker of AI-generated text
completed aiGenerated The turn is over. Not merely the current stream: a turn waiting on an approval has not completed
error message The turn failed. Followed by completed
cards cards[] Generative cards — camelCase, string enums
navigation url The assistant called navigate_to. Route however your framework routes
confirmationRequired actionId, tool, message A gated action is withheld. Answer with POST /mentor/approve?actionId=…&approved=true|false — the stream stays open and resumes on your verdict
const response = await fetch('/mentor/chat?message=' + encodeURIComponent(text));
const reader   = response.body.getReader();
const decoder  = new TextDecoder();
let buffer     = '';

while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });   // a read can end mid-frame or mid-character
    const lines = buffer.split('\n');
    buffer = lines.pop();                                  // keep the incomplete tail for the next read
    for (const line of lines) {
        if (!line.startsWith('data:')) continue;
        const event = JSON.parse(line.slice(5));
        if (event.type === 'chunk')     appendText(event.text);
        if (event.type === 'completed') finalize();
        if (event.type === 'error')     showError(event.message);
    }
}

With images — use POST with a JSON body (a base64 image doesn't fit in a query string). Everything else is identical:

const response = await fetch('/mentor/chat', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
    body: JSON.stringify({
        message: text,
        attachments: [
            { mimeType: 'image/png', dataBase64: '<base64, no data: prefix>', fileName: 'screenshot.png' },
            { mimeType: 'image/jpeg', url: 'https://cdn.example.com/product.jpg' },
        ],
    }),
});
// …read the stream exactly as above

Requires options.EnableImageInput = true and a vision-capable model — see Multimodal image input.


Option B — SignalR (full feature set)

All 18 events. SSE covers the six that decide what the user sees; SignalR adds the rest — UI actions, RAG citations, team collaboration, per-action feedback, MCP and hosted-tool status, busy state. Choose it when your client drives the page, not only the conversation.

npm install @microsoft/signalr
import * as signalR from '@microsoft/signalr';

const connection = new signalR.HubConnectionBuilder()
    .withUrl('/mentor-hub')
    .withAutomaticReconnect()
    .build();

// ── Subscribe to ALL events (see complete reference below) ───────────────────
connection.on('ReplyStarted',         reply        => markAiGenerated(reply.aiGenerated));
connection.on('StreamingChunk',       chunk        => appendText(chunk));
connection.on('StreamingCompleted',   ()           => finalize());
connection.on('BusyChanged',          busy         => setSpinner(busy));
connection.on('Error',                msg          => showError(msg));
connection.on('ActionExecuting',      action       => showActionBar(action));
connection.on('ActionCompleted',      action       => hideActionBar(action));
connection.on('ActionFailed',         error        => showActionError(error));
connection.on('ConfirmationRequired', (id, tool, msg) => showConfirmDialog(id, tool, msg));
connection.on('NavigationRequested',  url          => router.push(url));
connection.on('RagSourcesReady',      sources      => showCitations(sources));
connection.on('TeamMemberSpeaking',   (team, role) => showTeamActivity(team, role));
connection.on('UIActionRequested',    (name, json) => executeUIAction(name, json));
connection.on('UIActionExecuting',    name         => onUIActionStart(name));
connection.on('UIActionCompleted',    name         => onUIActionEnd(name));
connection.on('McpServerStatusChanged', (name, ok) => setMcpBadge(name, ok));
connection.on('HostedToolsDeclared',  flags        => showHostedToolBadges(flags)); // once, on connect
connection.on('GeneratedImages',      images       => attachImages(images));        // data: URIs or absolute URLs
connection.on('Cards',                cards        => renderCards(cards));          // numeric enums over SignalR, see below

await connection.start();

// ── Send messages ─────────────────────────────────────────────────────────────
// Always two arguments — see the note under the protocol reference.
await connection.invoke('SendMessage', 'Mostrami gli ordini pending', null);

// ── Send a message in the page's language ──────────────────────────────────────
// One object; language is a MentorLanguage name or ISO code: the turn in Italian, whatever the application's.
await connection.invoke('SendUserMessage', { text: 'Mostrami gli ordini pending', attachments: null, language: 'it' });

// ── Send a message with images (multimodal) ──────────────────────────────────
// The second argument carries the images. It is never optional on the wire: a text-only turn passes null (see the note under the protocol reference).
await connection.invoke('SendMessage', 'Cosa non va in questo screenshot?', [
    { mimeType: 'image/png', dataBase64: '<base64, no data: prefix>', fileName: 'error.png' },
    { mimeType: 'image/jpeg', url: 'https://cdn.example.com/product.jpg' },
]);

// ── Cancel current request — use HTTP, NOT a hub method ──────────────────────
// Same reason as HITL below: SignalR dispatches at most one hub invocation at a time per
// connection, so a CancelRequest hub call queues behind the still-running SendMessage and
// only fires after the turn it was meant to abort has already finished.
// Owner-checked: send the credentials the hub connection uses. Cookies: credentials: 'include'.
// A bearer token: an Authorization header, see "Calling approve, cancel and session from an
// authenticated client".
await fetch(`/mentor/cancel?connectionId=${connection.connectionId}`, { method: 'POST', credentials: 'include' });

// ── Reset conversation ────────────────────────────────────────────────────────
await connection.invoke('ResetSession');

// ── Confirm / reject a HITL dialog — use HTTP, NOT a hub method ──────────────
// SignalR processes hub messages sequentially per connection. While SendMessage
// is awaiting confirmation, the dispatcher cannot process any other hub message
// from the same connection — invoking RespondToApproval via hub would deadlock.
// Owner-checked like Stop: the same credentials.
await fetch(`/mentor/approve?actionId=${actionId}&approved=true`,  { method: 'POST', credentials: 'include' });  // approve
await fetch(`/mentor/approve?actionId=${actionId}&approved=false`, { method: 'POST', credentials: 'include' });  // reject

Complete SignalR Protocol Reference

Server → Client events
Event Parameters When fired What to do
ReplyStarted reply: { aiGenerated: true } (MentorReplyStarted in .NET) Once before every reply of the model, before its first StreamingChunk — also for a reply that does not stream, and for a specialist's or a team's Mark the reply as AI-generated (see Telling people they are talking to an AI in the MentorAgent README). Register it even with an empty handler: a client that does not still works, but SignalR logs a Warning for every reply ("Failed to find handler" in .NET, "No client method with the name…" in JavaScript)
StreamingChunk chunk: string Each streaming token from the AI Append text to the chat bubble
StreamingCompleted — A streaming segment ended: the reply finished, or the turn was stopped or failed. It also fires mid-turn, right before ConfirmationRequired, so the explanation is committed before the dialog appears Finalize the current bubble. Do not use it to re-enable input: a turn parked on a confirmation has not ended, and a turn refused up front (message too long, rate limit, safety or out-of-scope refusal) sends Error + BusyChanged(false) with no StreamingCompleted. Gate the composer on BusyChanged
BusyChanged busy: boolean AI starts/stops processing Show/hide loading spinner
Error message: string Critical error (rate limit, safety block, a provider failure — including one the provider reports inside the stream, such as a 429 on the Responses API, since rc.14) Show error message to user
ActionExecuting action: string A tool/agent is executing. action is the text to show — the action's description, or a routing, team or hosted-tool label — not the tool name, which ActionCompleted carries Show action feedback bar (e.g. "Consulting specialist…")
ActionCompleted action: string Tool execution succeeded Hide feedback bar
ActionFailed error: string Tool execution failed Show error in feedback bar
ConfirmationRequired actionId: string, toolName: string, message: string Destructive action needs approval Show confirmation dialog, then POST /mentor/approve?actionId=...&approved=true\|false (HTTP — not a hub method, see HITL note)
NavigationRequested url: string AI navigated after an action, or user asked to navigate Call router.push(url) or equivalent
RagSourcesReady sources: RagSource[] The RAG documents the finished answer cited (sent once the answer is complete; no event when it cites none) — and the pages cited by the provider's hosted web search, in the same shape Show citation chips below the AI message
GeneratedImages images: string[] The provider's hosted image-generation tool produced images Attach to the message being committed; each entry is a data: URI or an absolute URL, ready for <img src>. Ignoring the event is safe
Cards cards: MentorCard[] A server-side tool returned generative-UI cards instead of prose Render them under the reply — fields, accent and buttons. See Generative UI — cards for the shape and for what a button does. One difference on this transport: the hub uses SignalR's default JSON protocol, so enums arrive as numbers: accent 0-4 = default, success, warning, danger, info, and action kind 0-2 = sendMessage, navigate, uiAction. Only the SSE cards frame uses string enums. Ignoring the event loses the cards: the model is told they are already on screen, so it will not repeat their content in text
TeamMemberSpeaking teamName: string, memberRole: string A GroupChat team member is speaking Show "Team analyzing · Data Analyst" in feedback bar
UIActionRequested actionName: string, parameterJson: string? AI invoked a client-side UI action Execute the registered handler for actionName
UIActionExecuting actionName: string UI action started Optional: show feedback
UIActionCompleted actionName: string UI action completed Optional: hide feedback
McpServerStatusChanged name: string, connected: boolean An MCP client server connected (true) or failed/disconnected (false) Update the MCP status badge (green/red) for that server
HostedToolsDeclared flags: number Once on connect, before any message Render your capability badge from this rather than mirroring the server's configuration in the client — see Hosted tools. The value is the MentorHostedTools bit flags
(client) onreconnecting / onclose error?: Error The SignalR client's own callbacks, not hub events: the connection dropped or closed Clear busy, the streaming text and an open confirmation. The turn in flight is lost with the connection, and the reconnected connection (a new scope) never sends BusyChanged(false) for it
(client) onreconnected connectionId?: string The SignalR client reconnected — on a new connection, with a new conversation Restore the saved session with POST /mentor/session (see What Novior.MentorAgent.Server exposes)
Client → Server methods
Method Parameters Description
UpdatePageContext snapshot: PageContextSnapshot Send current page state before each message. Call before SendMessage
SendMessage text: string, attachments: MentorAttachment[] \| null Send a user message to the AI. attachments carries images for a multimodal turn. For a text-only turn pass null (or []). The argument is required on the wire, see the note below. Each item is { mimeType, dataBase64? , url?, fileName? } — exactly one of dataBase64 / url. An image-only turn (empty text) is valid
SendUserMessage message: { text, attachments, language } Sends a user message as SendMessage does, with the language of this turn: the name ("Italian") or the ISO code ("it") of a MentorLanguage, in any case. A number, a boolean or a name that is not one counts as the application's Language, with no error; so does no language. text and attachments are those of SendMessage. The WebAssembly client calls it only when its widget has a Language
CancelRequest — ⚠️ Only works while idle. SignalR sequential dispatch means it cannot run while SendMessage is streaming — exactly when Stop is needed. Use POST /mentor/cancel?connectionId=... instead
ResetSession — Start a new conversation (a fresh AgentSession)
RespondToApproval requestId: string, approved: boolean ⚠️ Do not use for HITL. SignalR sequential dispatch causes a deadlock while SendMessage is awaiting. Use POST /mentor/approve instead (see below)

⚠️ SendMessage takes two arguments — always

connection.invoke('SendMessage', text, null);          // ✅ text-only
connection.invoke('SendMessage', text, attachments);   // ✅ with images
connection.invoke('SendMessage', text);                // ❌ rejected by the server

The hub method's second parameter is declared with a C# default, but a default value never reaches the wire. SignalR binds invocation arguments positionally by count, inside the hub protocol's parser — before dispatch, before any hub filter, and before the method body — so a one-argument invocation fails with:

InvalidDataException: Invocation provides 1 argument(s) but target expects 2.

Worse, EnableDetailedErrors is off by default, so what your client actually sees is the opaque "Failed to invoke 'SendMessage' due to an error on the server" — which looks like a transport or authentication problem rather than an argument-count one. If you are staring at that message, this is almost certainly why.

The same rule applies to every SignalR client language, not just JavaScript: hub methods cannot be overloaded and have no optional parameters. Pass null (or [] — they behave identically) for a text-only turn.

Rendering the Cards event

Cards is the one event whose payload you lose by ignoring it. The model is told the cards are already on screen, so it deliberately does not repeat their content in prose — a client that drops the frame shows the user a reply referring to something that was never rendered.

conn.on('Cards', (cards: MentorCard[]) => setCards(cards));
{cards.map((c, i) => (
    // over SignalR enums arrive as numbers: accent 0-4 = default, success, warning, danger, info
    <article key={i} className={`card card--${['default', 'success', 'warning', 'danger', 'info'][c.accent] ?? 'default'}`}>
        {c.imageUrl && <img src={c.imageUrl} alt="" />}
        <h4>{c.title}</h4>
        {c.subtitle && <p className="sub">{c.subtitle}</p>}

        {(c.fields ?? []).map((f, j) => (
            <div key={j} className="row"><span>{f.label}</span><b>{f.value}</b></div>
        ))}

        {(c.actions ?? []).map((a, j) => (
            <button key={j} onClick={() =>
                // kind 0 = SendMessage, 1 = Navigate, 2 = UIAction (numbers over SignalR)
                a.kind === 1 ? router.push(a.value)
              : a.kind === 2 ? runLocalAction(a.value)   // your own client-side UI action registry
              :                sendMessage(a.value)
            }>{a.label}</button>
        ))}
    </article>
))}

The buttons are written by the tool that produced the card, never by the model's prose — which is what makes it safe for a card to carry actions at all.

PageContextSnapshot object

Sent before each message so the AI knows the current page state and available UI actions:

interface PageContextSnapshot {
    pageName?: string;                // e.g. "Orders"
    contextData: Record<string, string | null>; // e.g. { activeFilter: "Pending", visibleRows: "15" }
    uiActions: UIActionInfo[];
}

interface UIActionInfo {
    name: string;           // snake_case, e.g. "highlight_row"
    description: string;    // shown to AI
    parameterHint?: string; // e.g. "integer: order ID" — kept by the hub and shown to the model as "(parameter: integer: order ID)"
}

The hint is the only thing that tells the model what shape the argument takes, so send one for every action that takes a parameter. The WebAssembly client (Novior.MentorAgent.Blazor) generates it from the handler's parameter type when you do not supply one, in the same form as Blazor Server (integer, string (A|B), array<string>, { id: integer, ... }).

A UI action requested through the hub is fire-and-forget: the server sends UIActionRequested and tells the model the action succeeded once the request is sent — the client's handler result is not waited for.

RagSource object

JSON property names use camelCase (System.Text.Json default serialization from C# MentorRagResult).

interface RagSource {
    content: string;    // document text injected into AI prompt
    sourceUrl?: string; // link for citation chip
    title?: string;     // display title for chip (falls back to sourceUrl)
    score: number;      // relevance score (higher = more relevant)
    metadata?: Record<string, string> | null; // the source's facts about the document, as the model saw them
}

Complete chat component — React

Two files: the hook that manages the SignalR connection, and the component that renders the UI.

All three components (React, Angular, Vue) do the same four things beyond the events:

  • Same identity on every call. Approve, cancel and session are HTTP calls, and they are owner-checked: they must carry the identity the hub connection carries, or they get 401 (see Calling approve, cancel and session from an authenticated client). Pass a getToken function when the hub requires a bearer token. It feeds the hub's accessTokenFactory and the Authorization header of every HTTP call. For cookie auth, omit it: the calls send credentials: 'include'.
  • HTTP answers are checked. A confirmation or a Stop that the server refuses shows an error in the chat instead of failing silently. A 404 is not an error: nothing was waiting for it any more (the turn had ended, or the answer had already arrived).
  • A dropped connection frees the UI. The reconnected connection is a new scope, and it never sends BusyChanged(false) for the turn that was running. onreconnecting and onclose clear busy, the streaming text and an open confirmation.
  • The conversation survives a reconnect. The session is saved after every StreamingCompleted and restored in onreconnected (see What Novior.MentorAgent.Server exposes).
// useMentorHub.ts
import { useEffect, useRef, useState } from 'react';
import * as signalR from '@microsoft/signalr';

// hubUrl: relative when the server is this page's origin, absolute for a server on another
// origin (see CORS). getToken: only when the hub requires a bearer token; pass a stable function
// (module-level or useCallback), since the connection is built once per hubUrl.
export function useMentorHub(hubUrl: string, getToken?: () => string | Promise<string>) {
    const connRef      = useRef<signalR.HubConnection | null>(null);
    // Origin for the HTTP side-channel (approve/cancel/session): the page's own origin for a
    // relative hub URL, the server's origin for an absolute one (cross-origin setups, see CORS).
    const baseUrl      = new URL(hubUrl, window.location.href).origin;
    const streamingRef = useRef('');  // ref to avoid stale closure in StreamingCompleted
    const [messages, setMessages]     = useState<{ role: string; text: string }[]>([]);
    const [streaming, setStreaming]   = useState('');
    const [busy, setBusy]             = useState(false);
    const [action, setAction]         = useState('');
    const [sources, setSources]       = useState<any[]>([]);
    const [confirm, setConfirm]       = useState<{ id: string; msg: string } | null>(null);
    const [mcpStatus, setMcpStatus]   = useState<Record<string, boolean>>({}); // server name → connected

    const showError = (text: string) => setMessages(m => [...m, { role: 'error', text }]);

    // The HTTP side-channel, with the hub's identity: the bearer token, or the cookies.
    // Resolves to null when the request does not get through at all (network, CORS).
    const http = async (path: string, init: RequestInit = {}) => {
        const token = await getToken?.();
        const headers = new Headers(init.headers);
        if (token) headers.set('Authorization', `Bearer ${token}`);
        return fetch(`${baseUrl}${path}`, { ...init, headers, credentials: 'include' }).catch(() => null);
    };

    useEffect(() => {
        const conn = new signalR.HubConnectionBuilder()
            .withUrl(hubUrl, getToken ? { accessTokenFactory: getToken } : {})
            .withAutomaticReconnect()
            .build();

        // A reconnect is a new connection, so a new scope: the turn that was running is lost and
        // nothing will send BusyChanged(false) for it. Free the UI ourselves.
        const clear = () => {
            setBusy(false); setAction(''); setConfirm(null);
            streamingRef.current = ''; setStreaming('');
        };
        conn.onreconnecting(clear);
        conn.onclose(clear);
        // Restore the conversation saved after the last StreamingCompleted (see below).
        conn.onreconnected(async () => {
            const saved = localStorage.getItem('mentor-session');
            if (!saved) return;
            const post = () => http(`/mentor/session?connectionId=${conn.connectionId}`, {
                method: 'POST', headers: { 'Content-Type': 'application/json' }, body: saved,
            });
            let r = await post();
            if (r?.status === 404) { await new Promise(ok => setTimeout(ok, 250)); r = await post(); }  // scope not registered yet
        });

        conn.on('ReplyStarted',         () => {});   // the marker of AI-generated text
        conn.on('StreamingChunk',       c => {
            streamingRef.current += c;
            setStreaming(p => p + c);
        });
        conn.on('StreamingCompleted',   async () => {
            // Read the text before clearing the ref: React runs the setMessages updater later,
            // when the ref would already be empty.
            const text = streamingRef.current;
            streamingRef.current = '';
            setStreaming('');
            if (text) setMessages(m => [...m, { role: 'assistant', text }]);   // no text: a segment closed before a confirmation
            const r = await http(`/mentor/session?connectionId=${conn.connectionId}`);   // save
            if (r?.status === 200) localStorage.setItem('mentor-session', await r.text());   // 204 = nothing yet
        });
        conn.on('BusyChanged',          b => setBusy(b));
        conn.on('Error',                e => setMessages(m => [...m, { role: 'error', text: e }]));
        conn.on('ActionExecuting',      (a: string) => setAction(a));
        conn.on('ActionCompleted',      (_: string) => setAction(''));
        conn.on('ActionFailed',         e => setAction(`Error: ${e}`));
        conn.on('ConfirmationRequired', (id, tool, msg) => setConfirm({ id, msg }));
        conn.on('NavigationRequested',  url => router.push(url)); // use your SPA router
        conn.on('RagSourcesReady',      s => setSources(s));
        conn.on('McpServerStatusChanged', (name, connected) =>   // drives the 🔌 MCP badge
            setMcpStatus(p => ({ ...p, [name]: connected })));
        conn.on('TeamMemberSpeaking',   (t, r) => setAction(`${t} · ${r}`));
        conn.on('UIActionExecuting',    name => setAction(`UI: ${name}`));
        conn.on('UIActionCompleted',    (_name: string) => setAction(''));
        conn.on('UIActionRequested',    (name, json) => {
            // dispatch to your own UI action handlers
            window.dispatchEvent(new CustomEvent('mentor-ui-action', { detail: { name, json } }));
        });

        conn.start();
        connRef.current = conn;
        return () => { conn.stop(); };
    }, [hubUrl]);

    const sendMessage = async (text: string, snapshot?: any) => {
        if (snapshot) await connRef.current?.invoke('UpdatePageContext', snapshot);
        setMessages(m => [...m, { role: 'user', text }]);
        // Two arguments always: SignalR matches hub methods by argument count.
        await connRef.current?.invoke('SendMessage', text, null);
    };

    const respond = async (id: string, approved: boolean) => {
        setConfirm(null);
        // HITL MUST use HTTP POST, not the hub: SignalR dispatches hub messages
        // sequentially per connection, so invoking a hub method while SendMessage
        // is still awaiting would deadlock. (See the protocol table above.)
        const r = await http(`/mentor/approve?actionId=${id}&approved=${approved}`, { method: 'POST' });
        if (!r?.ok && r?.status !== 404) showError(`Your answer did not reach the server (${r ? `HTTP ${r.status}` : 'no response'}).`);  // 404: nothing waits for it any more
    };

    // Cancel over HTTP, not the hub — a hub call cannot run while SendMessage is streaming.
    const cancel  = async () => {
        const r = await http(`/mentor/cancel?connectionId=${connRef.current?.connectionId}`, { method: 'POST' });
        if (r?.status === 404) showError('The connection was closed, and its turn stopped with it: reconnect to go on.');  // 404: the connection is gone
        else if (!r?.ok) showError(`Stop did not reach the server (${r ? `HTTP ${r.status}` : 'no response'}).`);
    };
    const reset   = () => { setMessages([]); connRef.current?.invoke('ResetSession'); };

    return { messages, streaming, busy, action, sources, confirm, mcpStatus, sendMessage, respond, cancel, reset };
}
// MentorChat.tsx
import { useState } from 'react';
import { useMentorHub } from './useMentorHub';

export function MentorChat() {
    const [input, setInput] = useState('');
    // Same origin, open hub: useMentorHub('/mentor-hub').
    // Server on another origin, bearer token: useMentorHub('https://api.example.com/mentor-hub', () => auth.getToken()).
    const { messages, streaming, busy, action, sources, confirm,
            sendMessage, respond, cancel, reset } = useMentorHub('/mentor-hub');

    const handleSend = () => {
        if (!input.trim() || busy) return;
        // Pass a PageContextSnapshot as second argument if you have page state:
        // sendMessage(input, { pageName: 'Orders', contextData: {}, uiActions: [] });
        sendMessage(input);
        setInput('');
    };

    return (
        <div className="mentor-chat">

            {/* Messages */}
            <div className="messages">
                {messages.map((m, i) => (
                    <div key={i} className={`bubble bubble--${m.role}`}>
                        {m.text}
                    </div>
                ))}
                {streaming && (
                    <div className="bubble bubble--assistant">
                        {streaming}<span className="cursor">▋</span>
                    </div>
                )}
                {busy && !streaming && <div className="typing">···</div>}
            </div>

            {/* Action feedback bar */}
            {action && (
                <div className="action-bar">
                    <span className="pulse" /> {action}
                </div>
            )}

            {/* RAG citations */}
            {sources.length > 0 && (
                <div className="citations">
                    {sources.map((s, i) => (
                        <a key={i} href={s.sourceUrl} target="_blank" className="citation-chip">
                            📄 {s.title ?? s.sourceUrl}
                        </a>
                    ))}
                </div>
            )}

            {/* Confirmation dialog */}
            {confirm && (
                <div className="confirm-dialog">
                    <p>{confirm.msg}</p>
                    <button onClick={() => respond(confirm.id, true)}>Confirm</button>
                    <button onClick={() => respond(confirm.id, false)}>Cancel</button>
                </div>
            )}

            {/* Input */}
            <div className="input-bar">
                <input
                    value={input}
                    onChange={e => setInput(e.target.value)}
                    onKeyDown={e => e.key === 'Enter' && handleSend()}
                    placeholder="Ask anything..."
                    disabled={busy}
                />
                {busy
                    ? <button onClick={cancel}>■ Stop</button>
                    : <button onClick={handleSend} disabled={!input.trim()}>Send</button>
                }
                <button onClick={reset} title="New conversation">↺</button>
            </div>
        </div>
    );
}

Complete chat component — Angular

Two files: the injectable service and the component. Same identity, checks and reconnect handling as the React component (see the list above it).

npm install @microsoft/signalr
// mentor-hub.service.ts
import { Injectable, InjectionToken, OnDestroy, inject, signal } from '@angular/core';
import * as signalR from '@microsoft/signalr';
import { Router } from '@angular/router';

// hubUrl: relative when the server is this page's origin, absolute for a server on another origin
// (see CORS). getToken: only when the hub requires a bearer token. Without a provider the service
// uses '/mentor-hub' and no token. In app.config.ts, for example:
//   { provide: MENTOR_HUB_CONFIG, useFactory: () => {
//       const auth = inject(AuthService);   // your own token source
//       return { hubUrl: 'https://api.example.com/mentor-hub', getToken: () => auth.getToken() };
//   } }
export interface MentorHubConfig { hubUrl: string; getToken?: () => string | Promise<string>; }
export const MENTOR_HUB_CONFIG = new InjectionToken<MentorHubConfig>('MENTOR_HUB_CONFIG',
    { providedIn: 'root', factory: () => ({ hubUrl: '/mentor-hub' }) });

@Injectable({ providedIn: 'root' })
export class MentorHubService implements OnDestroy {

    // Reactive state — use in templates with {{ messages() }}
    messages     = signal<{ role: string; text: string }[]>([]);
    streaming    = signal('');
    busy         = signal(false);
    currentAction = signal('');
    ragSources   = signal<any[]>([]);
    confirmation = signal<{ id: string; tool: string; message: string } | null>(null);
    mcpStatus    = signal<Record<string, boolean>>({});   // server name → connected

    private config = inject(MENTOR_HUB_CONFIG);
    // Origin for the HTTP side-channel (approve/cancel/session): the page's own origin for a
    // relative hub URL, the server's origin for an absolute one.
    private baseUrl = new URL(this.config.hubUrl, window.location.href).origin;
    private connection: signalR.HubConnection;

    constructor(private router: Router) {
        const getToken = this.config.getToken;
        this.connection = new signalR.HubConnectionBuilder()
            .withUrl(this.config.hubUrl, getToken ? { accessTokenFactory: getToken } : {})
            .withAutomaticReconnect()
            .build();

        this.registerHandlers();
        this.connection.start();
    }

    // The HTTP side-channel, with the hub's identity: the bearer token, or the cookies.
    // Resolves to null when the request does not get through at all (network, CORS).
    private async http(path: string, init: RequestInit = {}): Promise<Response | null> {
        const token = await this.config.getToken?.();
        const headers = new Headers(init.headers);
        if (token) headers.set('Authorization', `Bearer ${token}`);
        return fetch(`${this.baseUrl}${path}`, { ...init, headers, credentials: 'include' }).catch(() => null);
    }

    private showError(text: string): void {
        this.messages.update(m => [...m, { role: 'error', text }]);
    }

    private registerHandlers(): void {
        // A reconnect is a new connection, so a new scope: the turn that was running is lost and
        // nothing will send BusyChanged(false) for it. Free the UI ourselves.
        const clear = () => {
            this.busy.set(false); this.currentAction.set(''); this.confirmation.set(null); this.streaming.set('');
        };
        this.connection.onreconnecting(clear);
        this.connection.onclose(clear);
        // Restore the conversation saved after the last StreamingCompleted (see below).
        this.connection.onreconnected(async () => {
            const saved = localStorage.getItem('mentor-session');
            if (!saved) return;
            const post = () => this.http(`/mentor/session?connectionId=${this.connection.connectionId}`, {
                method: 'POST', headers: { 'Content-Type': 'application/json' }, body: saved,
            });
            let r = await post();
            if (r?.status === 404) { await new Promise(ok => setTimeout(ok, 250)); r = await post(); }  // scope not registered yet
        });

        this.connection.on('ReplyStarted',         () => {});   // the marker of AI-generated text
        this.connection.on('StreamingChunk',       (c: string)  => this.streaming.update(p => p + c));
        this.connection.on('StreamingCompleted',   async ()     => {
            const text = this.streaming();
            this.streaming.set('');
            if (text) this.messages.update(m => [...m, { role: 'assistant', text }]);   // no text: a segment closed before a confirmation
            const r = await this.http(`/mentor/session?connectionId=${this.connection.connectionId}`);   // save
            if (r?.status === 200) localStorage.setItem('mentor-session', await r.text());   // 204 = nothing yet
        });
        this.connection.on('BusyChanged',          (b: boolean) => this.busy.set(b));
        this.connection.on('Error',                (msg: string)=> this.messages.update(m => [...m, { role: 'error', text: msg }]));
        this.connection.on('ActionExecuting',      (a: string)  => this.currentAction.set(a));
        this.connection.on('ActionCompleted',      (_: string)  => this.currentAction.set(''));
        this.connection.on('ActionFailed',         (e: string)  => this.currentAction.set(`Error: ${e}`));
        this.connection.on('ConfirmationRequired', (id: string, tool: string, msg: string) =>
            this.confirmation.set({ id, tool, message: msg }));
        this.connection.on('NavigationRequested',  (url: string)=> this.router.navigateByUrl(url));
        this.connection.on('RagSourcesReady',      (s: any[])   => this.ragSources.set(s));
        this.connection.on('McpServerStatusChanged', (name: string, connected: boolean) =>  // 🔌 MCP badge
            this.mcpStatus.update(m => ({ ...m, [name]: connected })));
        this.connection.on('TeamMemberSpeaking',   (t: string, r: string) => this.currentAction.set(`${t} · ${r}`));
        this.connection.on('UIActionExecuting',    (name: string) => this.currentAction.set(`UI: ${name}`));
        this.connection.on('UIActionCompleted',    (_: string)  => this.currentAction.set(''));
        this.connection.on('UIActionRequested',    (name: string, json: string | null) => {
            // Dispatch to your own UI action handlers
            document.dispatchEvent(new CustomEvent('mentor-ui-action', { detail: { name, json } }));
        });
    }

    async sendMessage(text: string, snapshot?: any): Promise<void> {
        if (snapshot) await this.connection.invoke('UpdatePageContext', snapshot);
        this.messages.update(m => [...m, { role: 'user', text }]);
        // Two arguments always: SignalR matches hub methods by argument count.
        await this.connection.invoke('SendMessage', text, null);
    }

    async respond(id: string, approved: boolean): Promise<void> {
        this.confirmation.set(null);
        // HITL MUST use HTTP POST, not the hub — SignalR dispatches hub messages
        // sequentially per connection, so a hub call while SendMessage awaits deadlocks.
        const r = await this.http(`/mentor/approve?actionId=${id}&approved=${approved}`, { method: 'POST' });
        if (!r?.ok && r?.status !== 404) this.showError(`Your answer did not reach the server (${r ? `HTTP ${r.status}` : 'no response'}).`);  // 404: nothing waits for it any more
    }

    // Cancel over HTTP, not the hub — a hub call cannot run while SendMessage is streaming.
    cancel  = async () => {
        const r = await this.http(`/mentor/cancel?connectionId=${this.connection.connectionId}`, { method: 'POST' });
        if (r?.status === 404) this.showError('The connection was closed, and its turn stopped with it: reconnect to go on.');  // 404: the connection is gone
        else if (!r?.ok) this.showError(`Stop did not reach the server (${r ? `HTTP ${r.status}` : 'no response'}).`);
    };
    reset   = () => { this.messages.set([]); this.connection.invoke('ResetSession'); };

    ngOnDestroy(): void { this.connection.stop(); }
}
// mentor-chat.component.ts
import { Component, signal } from '@angular/core';
import { MentorHubService } from './mentor-hub.service';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

@Component({
    selector: 'app-mentor-chat',
    standalone: true,
    imports: [CommonModule, FormsModule],
    template: `
        <div class="mentor-chat">

            
            <div class="messages">
                @for (m of hub.messages(); track $index) {
                    <div class="bubble" [class]="'bubble--' + m.role">{{ m.text }}</div>
                }
                @if (hub.streaming()) {
                    <div class="bubble bubble--assistant">
                        {{ hub.streaming() }}<span class="cursor">▋</span>
                    </div>
                }
                @if (hub.busy() && !hub.streaming()) {
                    <div class="typing">···</div>
                }
            </div>

            
            @if (hub.currentAction()) {
                <div class="action-bar">
                    <span class="pulse"></span> {{ hub.currentAction() }}
                </div>
            }

            
            @if (hub.ragSources().length > 0) {
                <div class="citations">
                    @for (s of hub.ragSources(); track $index) {
                        <a [href]="s.sourceUrl" target="_blank" class="citation-chip">
                            📄 {{ s.title ?? s.sourceUrl }}
                        </a>
                    }
                </div>
            }

            
            @if (hub.confirmation(); as c) {
                <div class="confirm-dialog">
                    <p>{{ c.message }}</p>
                    <button (click)="hub.respond(c.id, true)">Confirm</button>
                    <button (click)="hub.respond(c.id, false)">Cancel</button>
                </div>
            }

            
            <div class="input-bar">
                <input [(ngModel)]="input" (keydown.enter)="send()"
                       placeholder="Ask anything..." [disabled]="hub.busy()" />
                @if (hub.busy()) {
                    <button (click)="hub.cancel()">■ Stop</button>
                } @else {
                    <button (click)="send()" [disabled]="!input.trim()">Send</button>
                }
                <button (click)="hub.reset()" title="New conversation">↺</button>
            </div>
        </div>
    `
})
export class MentorChatComponent {
    input = '';
    constructor(public hub: MentorHubService) {}
    send() {
        if (!this.input.trim() || this.hub.busy()) return;
        this.hub.sendMessage(this.input);
        this.input = '';
    }
}

Complete chat component — Vue

Same identity, checks and reconnect handling as the React component (see the list above it).

npm install @microsoft/signalr
// useMentorHub.ts
import { ref, onUnmounted } from 'vue';
import * as signalR from '@microsoft/signalr';
import { useRouter } from 'vue-router';

// hubUrl: relative when the server is this page's origin, absolute for a server on another
// origin (see CORS). getToken: only when the hub requires a bearer token.
export function useMentorHub(hubUrl: string, getToken?: () => string | Promise<string>) {
    const router = useRouter();
    // Origin for the HTTP side-channel (approve/cancel/session): the page's own origin for a
    // relative hub URL, the server's origin for an absolute one.
    const baseUrl = new URL(hubUrl, window.location.href).origin;

    const messages      = ref<{ role: string; text: string }[]>([]);
    const streaming     = ref('');
    const busy          = ref(false);
    const currentAction = ref('');
    const ragSources    = ref<any[]>([]);
    const confirmation  = ref<{ id: string; tool: string; message: string } | null>(null);
    const mcpStatus     = ref<Record<string, boolean>>({});   // server name → connected

    const showError = (text: string) => messages.value.push({ role: 'error', text });

    // The HTTP side-channel, with the hub's identity: the bearer token, or the cookies.
    // Resolves to null when the request does not get through at all (network, CORS).
    const http = async (path: string, init: RequestInit = {}) => {
        const token = await getToken?.();
        const headers = new Headers(init.headers);
        if (token) headers.set('Authorization', `Bearer ${token}`);
        return fetch(`${baseUrl}${path}`, { ...init, headers, credentials: 'include' }).catch(() => null);
    };

    const connection = new signalR.HubConnectionBuilder()
        .withUrl(hubUrl, getToken ? { accessTokenFactory: getToken } : {})
        .withAutomaticReconnect()
        .build();

    // A reconnect is a new connection, so a new scope: the turn that was running is lost and
    // nothing will send BusyChanged(false) for it. Free the UI ourselves.
    const clear = () => {
        busy.value = false; currentAction.value = ''; confirmation.value = null; streaming.value = '';
    };
    connection.onreconnecting(clear);
    connection.onclose(clear);
    // Restore the conversation saved after the last StreamingCompleted (see below).
    connection.onreconnected(async () => {
        const saved = localStorage.getItem('mentor-session');
        if (!saved) return;
        const post = () => http(`/mentor/session?connectionId=${connection.connectionId}`, {
            method: 'POST', headers: { 'Content-Type': 'application/json' }, body: saved,
        });
        let r = await post();
        if (r?.status === 404) { await new Promise(ok => setTimeout(ok, 250)); r = await post(); }  // scope not registered yet
    });

    connection.on('ReplyStarted',         () => {});   // the marker of AI-generated text
    connection.on('StreamingChunk',       (c: string)  => streaming.value += c);
    connection.on('StreamingCompleted',   async ()     => {
        if (streaming.value) messages.value.push({ role: 'assistant', text: streaming.value });   // no text: a segment closed before a confirmation
        streaming.value = '';
        const r = await http(`/mentor/session?connectionId=${connection.connectionId}`);   // save
        if (r?.status === 200) localStorage.setItem('mentor-session', await r.text());   // 204 = nothing yet
    });
    connection.on('BusyChanged',          (b: boolean) => busy.value = b);
    connection.on('Error',                (msg: string)=> messages.value.push({ role: 'error', text: msg }));
    connection.on('ActionExecuting',      (a: string)  => currentAction.value = a);
    connection.on('ActionCompleted',      (_: string)  => currentAction.value = '');
    connection.on('ActionFailed',         (e: string)  => currentAction.value = `Error: ${e}`);
    connection.on('ConfirmationRequired', (id: string, tool: string, msg: string) =>
        confirmation.value = { id, tool, message: msg });
    connection.on('NavigationRequested',  (url: string)=> router.push(url));
    connection.on('RagSourcesReady',      (s: any[])   => ragSources.value = s);
    connection.on('McpServerStatusChanged', (name: string, connected: boolean) =>  // 🔌 MCP badge
        mcpStatus.value = { ...mcpStatus.value, [name]: connected });
    connection.on('TeamMemberSpeaking',   (t: string, r: string) => currentAction.value = `${t} · ${r}`);
    connection.on('UIActionExecuting',    (name: string)=> currentAction.value = `UI: ${name}`);
    connection.on('UIActionCompleted',    (_: string)  => currentAction.value = '');
    connection.on('UIActionRequested',    (name: string, json: string | null) => {
        // Dispatch to your own UI action handlers
        document.dispatchEvent(new CustomEvent('mentor-ui-action', { detail: { name, json } }));
    });

    connection.start();

    onUnmounted(() => connection.stop());

    const sendMessage = async (text: string, snapshot?: any) => {
        if (snapshot) await connection.invoke('UpdatePageContext', snapshot);
        messages.value.push({ role: 'user', text });
        // Two arguments always: SignalR matches hub methods by argument count.
        await connection.invoke('SendMessage', text, null);
    };

    const respond = async (id: string, approved: boolean) => {
        confirmation.value = null;
        // HITL MUST use HTTP POST, not the hub (sequential hub dispatch would deadlock).
        const r = await http(`/mentor/approve?actionId=${id}&approved=${approved}`, { method: 'POST' });
        if (!r?.ok && r?.status !== 404) showError(`Your answer did not reach the server (${r ? `HTTP ${r.status}` : 'no response'}).`);  // 404: nothing waits for it any more
    };

    // Cancel over HTTP, not the hub — a hub call cannot run while SendMessage is streaming.
    const cancel = async () => {
        const r = await http(`/mentor/cancel?connectionId=${connection.connectionId}`, { method: 'POST' });
        if (r?.status === 404) showError('The connection was closed, and its turn stopped with it: reconnect to go on.');  // 404: the connection is gone
        else if (!r?.ok) showError(`Stop did not reach the server (${r ? `HTTP ${r.status}` : 'no response'}).`);
    };
    const reset  = () => { messages.value = []; connection.invoke('ResetSession'); };

    return { messages, streaming, busy, currentAction, ragSources, confirmation, mcpStatus,
             sendMessage, respond, cancel, reset };
}

<template>
    <div class="mentor-chat">

        
        <div class="messages">
            <div v-for="(m, i) in messages" :key="i" :class="`bubble bubble--${m.role}`">
                {{ m.text }}
            </div>
            <div v-if="streaming" class="bubble bubble--assistant">
                {{ streaming }}<span class="cursor">▋</span>
            </div>
            <div v-if="busy && !streaming" class="typing">···</div>
        </div>

        
        <div v-if="currentAction" class="action-bar">
            <span class="pulse" /> {{ currentAction }}
        </div>

        
        <div v-if="ragSources.length" class="citations">
            <a v-for="(s, i) in ragSources" :key="i"
               :href="s.sourceUrl" target="_blank" class="citation-chip">
                📄 {{ s.title ?? s.sourceUrl }}
            </a>
        </div>

        
        <div v-if="confirmation" class="confirm-dialog">
            <p>{{ confirmation.message }}</p>
            <button @click="respond(confirmation.id, true)">Confirm</button>
            <button @click="respond(confirmation.id, false)">Cancel</button>
        </div>

        
        <div class="input-bar">
            <input v-model="input" @keydown.enter="send"
                   placeholder="Ask anything..." :disabled="busy" />
            <button v-if="busy" @click="cancel">■ Stop</button>
            <button v-else @click="send" :disabled="!input.trim()">Send</button>
            <button @click="reset" title="New conversation">↺</button>
        </div>
    </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useMentorHub } from './useMentorHub';

const input = ref('');
// Same origin, open hub: useMentorHub('/mentor-hub').
// Server on another origin, bearer token: useMentorHub('https://api.example.com/mentor-hub', () => auth.getToken()).
const { messages, streaming, busy, currentAction, ragSources, confirmation,
        sendMessage, respond, cancel, reset } = useMentorHub('/mentor-hub');

function send() {
    if (!input.value.trim() || busy.value) return;
    sendMessage(input.value);
    input.value = '';
}
</script>

.NET MAUI / console

// Install: Microsoft.AspNetCore.SignalR.Client
var connection = new HubConnectionBuilder()
    .WithUrl("http://your-api/mentor-hub")
    .WithAutomaticReconnect()
    .Build();

connection.On<object>("ReplyStarted",        _ => { });   // the marker of AI-generated text
connection.On<string>("StreamingChunk",      chunk => Console.Write(chunk));
connection.On(        "StreamingCompleted",  ()    => Console.WriteLine());
connection.On<bool>(  "BusyChanged",         busy  => { /* show spinner */ });
connection.On<string>("Error",               msg   => Console.WriteLine($"Error: {msg}"));
connection.On<string>("ActionExecuting",     act   => Console.WriteLine($"[{act}]"));
connection.On<string>("ActionCompleted",     _     => { });
connection.On<string>("ActionFailed",        err   => Console.WriteLine($"Failed: {err}"));
connection.On<string, string, string>("ConfirmationRequired", async (id, tool, msg) => {
    Console.WriteLine($"Confirm: {msg} [y/n]");
    var approved = Console.ReadLine() == "y";
    // HITL MUST use HTTP POST, not the hub — a hub call while SendMessage awaits would deadlock.
    // Owner-checked: if the hub connection is authenticated, send the same bearer token here
    // (http.DefaultRequestHeaders.Authorization) or the answer is refused with 401.
    using var http = new HttpClient();
    await http.PostAsync($"http://your-api/mentor/approve?actionId={id}&approved={approved.ToString().ToLower()}", null);
});
connection.On<string>("NavigationRequested", url => Console.WriteLine($"Navigate: {url}"));
connection.On<JsonElement[]>("RagSourcesReady", s => Console.WriteLine($"{s.Length} sources"));
connection.On<string, string>("TeamMemberSpeaking", (t, r) => Console.WriteLine($"[{t}] {r}"));
connection.On<string, string?>("UIActionRequested", (name, json) => Console.WriteLine($"UI: {name}({json})"));
connection.On<string, bool>("McpServerStatusChanged", (name, ok) => Console.WriteLine($"MCP {name}: {(ok ? "online" : "offline")}"));

await connection.StartAsync();
// Two arguments always — a C# default does not travel over the wire.
await connection.InvokeAsync("SendMessage", "Ciao!", null);
Console.ReadLine();
await connection.StopAsync();

Protecting the endpoints

MapMentorAgentServer() maps everything unauthenticated by default, because the package cannot know how your application authenticates. That default is fine for a single-user demo and wrong for anything else, so here is what each endpoint exposes and what to do about it.

Endpoint Open by default What an anonymous caller can do Protect it with
/mentor-hub ✅ Run turns — your model spend configureTransports
GET/POST /mentor/chat ✅ Run turns — your model spend configureTransports
POST /mentor/approve — For a confirmation raised over the hub: nothing, because only the principal it was raised for may answer it. A confirmation raised over SSE (or in-process) has no recorded owner, so anyone holding its actionId can answer it. Keep /mentor/chat authenticated and treat the actionId as a secret. When the hub connection closes, its turn ends with it, and a late answer gets 404 (enforced for hub connections)
POST /mentor/cancel — Nothing: owner-only, same rule (enforced)
GET/POST /mentor/session — Nothing: owner-only, same rule (enforced)
GET /mentor/admin/metrics — Nothing, when DashboardRole is set DashboardRole
GET /mentor/tour ✅ Read the tour — public screen names and the descriptions of the actions that need no role, identical for every user, no counts and no per-user data. Deliberate —
/mcp (if mapped) ✅ Call every ungated [MentorAction] as an MCP tool (gated actions are withheld there) MapMentorAgentMcp(configure: ...)
/a2a + /.well-known/agent-card.json (if mapped) ✅ Run A2A tasks against your assistant (your model spend) and read the agent card MapMentorAgentA2A(configure: ...)

MCP and A2A are mapped by their own calls, so configureTransports does not reach them. Both take the same kind of convention callback:

app.MapMentorAgentMcp(configure: e => e.RequireAuthorization());
app.MapMentorAgentA2A(configure: e => e.RequireAuthorization());   // applied to /a2a and the agent card

Requiring authentication on the transports

app.MapMentorAgentServer(configureTransports: e => e.RequireAuthorization());

configureTransports is applied to the hub and both /mentor/chat endpoints — the three that run a turn. It takes the ordinary IEndpointConventionBuilder, so a named policy works too:

app.MapMentorAgentServer(configureTransports: e => e.RequireAuthorization("MentorUsers"));

⚠️ Unauthenticated callers, memory and rate limiting

A caller with no principal is keyed by AnonymousIdentity. The default, PerSession, gives each hub connection its own key, and each GET/POST /mentor/chat request its own too, because every SSE request is its own session. Two anonymous callers therefore never read each other's memories, and an anonymous SSE caller has no memory across requests.

AnonymousIdentity = MentorAnonymousIdentity.Shared puts every anonymous caller on the single key "anonymous". One user's remembered facts are then injected into another user's turns, and MemoryAutoCapture (on by default) keeps writing into that shared bucket. Measured before PerSession became the default: one anonymous POST /mentor/chat stored a private code, and a second, entirely separate anonymous request read it back. Use Shared only for a single-user host.

MentorAgent logs a warning at startup when /mentor/chat is left without authorization and either UseMemoryContext or MemoryAutoCapture is true. That includes the defaults (MemoryAutoCapture = true, UseMemoryContext = false), even though nothing is captured until UseMemoryContext is on. Require authentication if callers must keep their memory across connections, or turn memory off:

options.UseMemoryContext  = false;
options.MemoryAutoCapture = false;

The same key drives RateLimitPerUser. Under PerSession, an anonymous caller gets a fresh allowance with every new hub connection, every SSE request and every A2A task, so the limit does not bound an anonymous client that reconnects, calls /mentor/chat repeatedly or sends tasks to /a2a. Under Shared, every anonymous caller spends one allowance. To bound anonymous spend, require authentication on the transports (configureTransports) and on A2A (MapMentorAgentA2A(configure: …)).

Why approve, cancel and session need no flag

Those three take an actionId or a connectionId and act on somebody's live conversation. They are owner-checked by the package: when the connection was opened by an authenticated principal, only that principal may use them — an anonymous caller gets 401, a different signed-in user gets 403. Being signed in is not the same as being the owner, which is why a plain RequireAuthorization() on them would not have been enough.

The principal is recognised by its NameIdentifier claim, else sub, else oid, else its name, so Windows authentication and JwtBearer with MapInboundClaims = false pass the check too. A name never matches another user's NameIdentifier.

When the connection carries no principal — an unauthenticated host, or an SSE client whose confirmation never passed through a hub connection — there is nothing to compare and behaviour is unchanged.

Calling approve, cancel and session from an authenticated client

The owner check compares the principal on the HTTP request with the one that opened the hub connection. These calls must therefore carry the same identity as the hub. A bare fetch does not. With bearer tokens it sends no Authorization header, and cross-origin it sends no cookies. Either way the request is anonymous and gets 401, and the confirmation, the Stop or the restore silently does nothing.

const connection = new signalR.HubConnectionBuilder()
    .withUrl(`${apiBase}/mentor-hub`, { accessTokenFactory: () => getToken() })
    .build();

// Bearer tokens: send the same token on the HTTP side
await fetch(`${apiBase}/mentor/approve?actionId=${id}&approved=true`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${await getToken()}` },
});

// Cookie auth, cross-origin: include the cookies (the CORS policy already has AllowCredentials)
await fetch(`${apiBase}/mentor/cancel?connectionId=${connection.connectionId}`, {
    method: 'POST',
    credentials: 'include',
});

Multimodal image input

Users can send images along with their message — a screenshot of an error, a photo of a receipt, a product picture — and the model reasons about them. Built on the Agent Framework's native multimodal API: the user turn becomes a ChatMessage with a TextContent plus one DataContent (inline) or UriContent (remote) per image.

Server setup (off by default):

builder.Services.AddMentorAgent(options =>
{
    options.ChatClient = azure.GetChatClient("gpt-4.1").AsIChatClient();   // must be vision-capable

    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
});

Every attachment is re-validated server-side against the allow-list, the size cap and the count cap before it reaches the model — client checks are only for fast feedback. A disallowed type or an oversized image is dropped with a warning log; images beyond MaxImagesPerMessage are dropped silently (the first N are kept), and with EnableImageInput off every attachment is ignored. The turn still runs with whatever passed.

SignalR message size — handled for you. Attachments travel inside the SendMessage hub invocation as base64, and SignalR's default MaximumReceiveMessageSize is only 32 KB — smaller than any real photo. Worse, exceeding it makes the server abort the connection, so the client sees no reply, no error and no busy indicator at all. When EnableImageInput is on, AddMentorAgentServer() therefore raises HubOptions<MentorHub>.MaximumReceiveMessageSize to MaxImageBytes × MaxImagesPerMessage × 4/3 + 512 KB. It is scoped to the MentorAgent hub, so your own hubs keep their limits, and it only ever raises a value you set yourself. Keep MaxImageBytes/MaxImagesPerMessage tight — they are what sizes this buffer.

Wire format

{
  "mimeType":   "image/png",       // must be in AllowedImageTypes
  "dataBase64": "iVBORw0KGgo…",    // inline bytes, NO "data:" prefix   ─┐ exactly
  "url":        null,              // …or a public https URL            ─┘ one of the two
  "fileName":   "screenshot.png"   // optional, display only
}
Transport How to send
SignalR connection.invoke('SendMessage', text, attachments) — always send both arguments; pass null when there are no images
SSE POST /mentor/chat with { "message": …, "attachments": [ … ] } (GET stays text-only)

React

import { useState, type ClipboardEvent } from 'react';
import type { HubConnection } from '@microsoft/signalr';

type Attachment = { mimeType: string; dataBase64?: string; url?: string; fileName?: string };  // the wire format above

const fileToAttachment = (file: File) => new Promise<Attachment>((resolve) => {
  const reader = new FileReader();
  reader.onload = () => {
    const result = String(reader.result);
    resolve({ mimeType: file.type, dataBase64: result.slice(result.indexOf(',') + 1), fileName: file.name });
  };
  reader.readAsDataURL(file);
});

// upload · paste · drag & drop · URL — all produce the same Attachment[]
export function Composer({ connection }: { connection: HubConnection }) {
  const [text, setText] = useState('');
  const [attachments, setAttachments] = useState<Attachment[]>([]);

  const onPaste = async (e: ClipboardEvent<HTMLTextAreaElement>) => {
    const files = [...e.clipboardData.items]
      .filter(i => i.kind === 'file')
      .map(i => i.getAsFile())
      .filter((f): f is File => !!f && f.type.startsWith('image/'));
    if (files.length) setAttachments(await Promise.all(files.map(fileToAttachment)));
  };

  const send = async () => {
    await connection.invoke('SendMessage', text, attachments.length ? attachments : null);
    setText('');
    setAttachments([]);
  };

  return (
    <>
      <textarea value={text} onChange={e => setText(e.target.value)} onPaste={onPaste} />
      <button onClick={send}>Send</button>
    </>
  );
}

Angular

async onFiles(files: FileList) {
  this.attachments = await Promise.all([...files].map(f => this.toAttachment(f)));
}

private toAttachment(file: File): Promise<Attachment> {
  return new Promise(resolve => {
    const reader = new FileReader();
    reader.onload = () => {
      const r = String(reader.result);
      resolve({ mimeType: file.type, dataBase64: r.slice(r.indexOf(',') + 1), fileName: file.name });
    };
    reader.readAsDataURL(file);
  });
}

async send() {
  await this.connection.invoke('SendMessage', this.text, this.attachments.length ? this.attachments : null);
  this.attachments = [];
}

Vue

<input type="file" accept="image/*" multiple @change="onFiles" />
<div @dragover.prevent @drop.prevent="onDrop">…</div>

<script setup>
const attachments = ref([]);

const toAttachment = file => new Promise(resolve => {
  const reader = new FileReader();
  reader.onload = () => {
    const r = String(reader.result);
    resolve({ mimeType: file.type, dataBase64: r.slice(r.indexOf(',') + 1), fileName: file.name });
  };
  reader.readAsDataURL(file);
});

const onFiles = async e => { attachments.value = await Promise.all([...e.target.files].map(toAttachment)); };
const onDrop  = async e => { attachments.value = await Promise.all([...e.dataTransfer.files].map(toAttachment)); };

const send = async () => {
  await connection.invoke('SendMessage', text.value, attachments.value.length ? attachments.value : null);
  attachments.value = [];
};
</script>

.NET MAUI / console

// MAUI: pick a photo from the gallery (or MediaPicker.CapturePhotoAsync() for the camera)
var photo = await MediaPicker.Default.PickPhotoAsync();
await using var stream = await photo!.OpenReadAsync();
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);

var attachments = new[]
{
    new { mimeType = photo.ContentType, dataBase64 = Convert.ToBase64String(ms.ToArray()), fileName = photo.FileName },
};

await connection.InvokeAsync("SendMessage", "Cosa vedi in questa foto?", attachments);

Rendering the thumbnail

const src = a.url ?? `data:${a.mimeType};base64,${a.dataBase64}`;

The bundled Blazor widget (Novior.MentorAgent / Novior.MentorAgent.Blazor) already implements upload, paste, drag & drop and URL out of the box — just set EnableImageInput = true.


Hosted tools (web search, code interpreter, file search, images, remote MCP)

The Agent Framework's provider-hosted tools give the model capabilities that run on the provider's infrastructure during inference — no code on your server, and nothing to implement in the client: they are configured here and their results simply appear in the streamed answer.

builder.Services.AddMentorAgent(options =>
{
    options.HostedTools = MentorHostedTools.WebSearch | MentorHostedTools.CodeInterpreter;

    // File search needs at least one vector store — without ids the tool is skipped (fail-closed)
    // options.HostedTools |= MentorHostedTools.FileSearch;
    // options.FileSearchVectorStoreIds = ["vs_abc123"];
    // options.FileSearchMaxResults     = 5;

    // Image generation. ⚠️ On Azure this is NOT enough on its own: Azure resolves the image
    // deployment from the x-ms-oai-image-generation-deployment HEADER, not from the tool payload.
    // Add it as a pipeline policy where you build the AzureOpenAIClient — see the samples'
    // Infrastructure/ImageDeploymentHeaderPolicy.cs — or every image turn fails with
    // "imagegen deployment must be provided through header".
    // options.HostedTools |= MentorHostedTools.ImageGeneration;
    // options.HostedImageModel = "gpt-image-1-mini";
    // options.HostedImageSize  = "1024x1024";   // the cost knob

    // Hosted MCP: the PROVIDER dials the server, so it must be reachable from the provider's
    // network (no localhost), and approval is enforced by the provider.
    // Not to be confused with options.McpServers, where this process is the MCP client.
    // options.HostedTools |= MentorHostedTools.HostedMcp;
    // options.HostedMcpServers = [
    //     new MentorHostedMcpServer
    //     {
    //         Name = "microsoft_learn", Url = "https://learn.microsoft.com/api/mcp",
    //         AllowedTools = ["microsoft_docs_search"], RequireApproval = true,
    //     }
    // ];
});

Provider support is not universal:

Client Function tools Web search Code interpreter File search Image gen Hosted MCP
Azure OpenAI / OpenAI — Responses ✅ ✅ ✅ ✅ ✅ ✅
Azure OpenAI / OpenAI — Chat Completions ✅ ✅¹ ❌ ❌ ❌ ❌
Foundry (AIProjectClient) ✅ ✅² ✅² ✅² ✅² ✅²

¹ Depends on the deployment; an unsupported one answers 400 unknown_parameter: web_search_options. Availability is per deployment too, not only per client type.

² Only through an IChatClient set on options.ChatClient. HostedTools is applied on Path A only: with a pre-built options.Agent (how AIProjectClient is wired under AI providers) it is ignored with a startup warning — declare hosted tools on that agent yourself.

To get the full set, switch to the Responses client:

var azure = new AzureOpenAIClient(endpoint, credential);
options.ChatClient = azure.GetResponsesClient().AsIChatClient("gpt-4.1");

// ⚠️ Required with Responses: the service owns the conversation and returns a conversation id, and
// AF refuses to combine that with a local ChatHistoryProvider — every turn would fail with
// "Only ConversationId or ChatHistoryProvider may be used, but not both".
options.UseServiceManagedHistory = true;   // disables MaxSessionMessages, ChatReducer + EnableCompaction

With model routing: the strong model gets the same tool list, so build StrongChatClient on a client that supports hosted tools too — otherwise turns work until one is escalated and then fail. MentorAgent warns at startup when both are configured.

Optional badge. options.ShowHostedToolsStatus = true adds an amber pill to the Blazor widget header listing the active tools. A custom client does not need to mirror the list in its own configuration: on connect the server sends

HostedToolsDeclared(int flags)     // MentorHostedTools bit flags

with the set it actually enabled. Render the badge from that. A hand-kept copy drifts the moment the server changes — it then claims tools the server dropped, or hides ones it gained. The event is purely additive, so a client that ignores it behaves exactly as before.

const HOSTED = [[1,'Web search'],[2,'Code interpreter'],[4,'File search'],
                [8,'Image generation'],[16,'Remote MCP tools']] as const;

conn.on('HostedToolsDeclared', (flags: number) =>
  setHostedTools(HOSTED.filter(([bit]) => flags & bit).map(([, name]) => name)));

Live activity over the wire

A hosted tool runs remotely and can take several seconds with nothing streamed. With options.ShowHostedToolActivity (default true) the server reports it as SignalR hub events — the SSE endpoint (/mentor/chat) has no frames for any of the signals below, so an SSE client shows no activity line or citations and cannot receive generated images. Use the hub if you enable image generation:

Signal Hub event What to render
Tool started ActionExecuting(label) "Searching the web… · .NET 10 release notes", "Running code…", "Searching your documents…", "Generating the image…"
Tool finished ActionCompleted(key) clear the feedback line
Sources cited by web search or file search RagSourcesReady(sources) the citation chips you already render for RAG — same payload shape. A document cited from a vector store has no sourceUrl (there is nothing to open): render the title and the snippet
Generated images GeneratedImages(string[]) attach to the message being committed; each entry is a data: URI or an absolute URL, ready for <img src>
Which hosted tools are on HostedToolsDeclared(int) sent once on connect, before any message: the active MentorHostedTools flags. Use it to render the capability badge instead of mirroring the list in the client — hosted tools are a server-side capability, and a hand-kept copy drifts the moment the server's configuration changes

Only GeneratedImages is new. A client that ignores it keeps working exactly as before — it simply won't show generated images. A base64 image is bulky, so it is sent as its own message rather than folded into the stream, and only when the provider actually produced one.

// React / Angular / Vue — the whole client-side change
conn.on('GeneratedImages', (images: string[]) => { pendingImages.current = images; });

// And, if you show a capability badge, take its contents from the server rather than
// keeping a copy: flags are MentorHostedTools (1 web search, 2 code interpreter,
// 4 file search, 8 image generation, 16 hosted MCP).
conn.on('HostedToolsDeclared', (flags: number) => setHostedTools(decodeHostedTools(flags)));

Also declared in the system prompt. MentorAgent lists the enabled hosted tools in the coordinator's instructions automatically. Without it a coordinator holding a long list of application actions — and told never to invent capabilities — answers from memory instead of searching or running code.

Limits by design. Hosted tools never reach MentorAgent's function-calling middleware (there is no local invocation to intercept), so role checks, HITL confirmation, action feedback and per-tool metrics do not apply to them.

Keeping them from firing when they are not needed

These are the expensive tools — a web search is billed per call, a file-search turn measured 8 249 input tokens against 2 870 for a plain one — and they used to be declared on every message, including "hello". Three independent controls, doing three different jobs:

options.EnableToolFiltering = true;        // required — all three live inside the semantic filter
options.EmbeddingGenerator  = embeddings;  // required — semantic, never keyword matching

options.FilterHostedTools           = true;   // default: declare a hosted tool only when relevant
options.HostedToolFilterMinScore    = 0.15f;  // default: a coarse pre-cut, keep it LOW
options.HostedToolDomainCheck       = true;   // default false: is this message about my app at all?
options.MaxHostedToolCallsPerSession = 10;    // 0 = unlimited: the only hard ceiling
  • Relevance (FilterHostedTools) answers which tool, if any. Probabilistic: it lowers how often a paid tool fires on a message that did not need it, without promising a maximum.
  • Domain gating (HostedToolDomainCheck) answers should this application spend anything on this message at all. A different question: "draw me a dog" scores high against the image tool because it genuinely is an image request, and is nonsense for a shop backend — which pays for the picture anyway. A small model decides, once per turn and only when a hosted tool already passed relevance, so ordinary conversation costs nothing.
  • The session cap (MaxHostedToolCallsPerSession) is the maximum, and it is what survives a message the other two get wrong. ⚠️ The counter lives in the orchestrator scope: over SignalR it spans the whole connection, but a POST /mentor/chat call is its own session — a stateless HTTP client therefore gets a per-request cap, not a per-user one.

Tune from data, not guesswork — every score is logged at Debug:

[MentorAgent] Tool filtering: no action cleared minScore=0.35 (best: get_product 0.256); the 3 best-ranked
              that need no confirmation were sent: get_product 0.256, search_products 0.242, search_customers 0.234.
[MentorAgent] Hosted tool relevance: hosted:web_search scored 0.040 (min 0.15) — skipped.
[MentorAgent] Hosted tool relevance: hosted:image_generation scored 0.352 (min 0.15) — declared.
[MentorAgent] Hosted-tool domain check → OUT of scope (from the turn's classifier call).
[MentorAgent] Hosted tools withheld: the request is outside this application's scope.
              hosted:image_generation would have run.
[MentorAgent] Tool filtering: 10/63 tools sent (7 core + 3/51 matched + 0/5 hosted, minScore=0.35).

Withholding a tool is only half the job. The system prompt is built once while the tool list is decided per turn, so a model told "you can generate images" and then handed no image tool resolves the contradiction by inventing — a real turn answered an image request with a fabricated URL introduced as "the image I created for you". MentorAgent therefore tells the model, in that same request, that the capability is gone and why, and requires a plain refusal. Nothing is sent on turns where nothing was withheld. It says the capabilities are not for this request, never that the request is outside the application: the gate is strict about paid tools, and a request it turns down may still be one the application's own actions answer (BUG-106). There is nothing to configure and nothing for your client to handle: the reply simply says "I can't generate images right now, but I can help you with…" instead of inventing a link.

Fail-open by design. All three controls run inside the semantic tool filter, which is installed only with EnableToolFiltering and an EmbeddingGenerator. Without them nothing is filtered and hosted tools keep firing on every turn — with a startup warning naming the options being ignored, because a control that is switched on but never runs is worse than one that is off. Losing a capability because a model was not configured is worse than costing more than expected, and there is deliberately no keyword fallback.


AI providers

// Azure OpenAI — Chat Completions
options.ChatClient = new AzureOpenAIClient(endpoint, credential)
    .GetChatClient("gpt-4o").AsIChatClient();

// OpenAI direct
options.ChatClient = new OpenAIClient("sk-...")
    .GetChatClient("gpt-4o").AsIChatClient();

// Ollama (local) — through its OpenAI-compatible endpoint: same SDK, no extra package
options.ChatClient = new OpenAIClient(
        new System.ClientModel.ApiKeyCredential("ollama"),   // Ollama ignores it; the SDK requires one
        new OpenAIClientOptions
        {
            Endpoint       = new Uri("http://localhost:11434/v1"),
            NetworkTimeout = TimeSpan.FromMinutes(20),          // a CPU model can take minutes on a long prompt
        })
    .GetChatClient("llama3.2").AsIChatClient();
// Ollama's default context (4096 tokens) is shorter than MentorAgent's prompt and is truncated from the
// start without an error — create a variant with `PARAMETER num_ctx 16384` (ollama create).

// Azure AI Foundry — requires AIAgent, and:
//    <PackageReference Include="Microsoft.Agents.AI.Foundry" Version="[1.17.0-preview.260804.1]" />
options.Agent = new AIProjectClient(endpoint, credential)
    .AsAIAgent(model: "gpt-4o", instructions: "You are a helpful assistant.");

Embedding model (optional)

Configure an embedding model to enable semantic tool filtering and semantic memory relevance (see Token & cost optimization). Everything works without it.

// Azure OpenAI
options.EmbeddingGenerator = new AzureOpenAIClient(endpoint, credential)
    .GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();

// OpenAI direct
options.EmbeddingGenerator = new OpenAIClient("sk-...")
    .GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();

The three-level agent model

MentorAgent uses a three-level orchestration architecture.

Level 1 — Direct actions

Plain C# methods on any DI-registered class become AI tools:

public class OrderService
{
    [Description("Get order details by order ID")]
    public async Task<Order> GetOrderAsync(string orderId) => ...;

    // Tool name = method name in snake_case ("Async" dropped): cancel_order
    [MentorAction(Description = "Cancel an order", RequiresConfirmation = true)]
    public async Task<string> CancelOrderAsync(string orderId) => ...;
}

Register in DI and scan:

builder.Services.AddScoped<OrderService>();
options.ScanAssemblies = [typeof(Program).Assembly];

Only public instance methods declared on the class itself are discovered (not inherited or static ones, and not an empty [Description]), at every level. A method with [Description] alone has no roles and no confirmation — those exist only on [MentorAction] — so give a method that changes data [MentorAction].

Level 2 — Specialized agents (Handoff)

[MentorAgent(Name = "OrderAgent", Description = "Handles all order-related operations")]
public class OrderAgent : IMentorAgent
{
    [Description("Process a refund for an order")]
    public async Task<string> ProcessRefundAsync(string orderId, decimal amount) => ...;
}

builder.Services.AddScoped<OrderAgent>();

Level 3 — Collaborative teams (Group Chat)

[MentorTeam(
    Name          = "AnalysisTeam",
    Description   = "Analyzes business proposals before execution",
    TriggerOn     = ["analyze", "evaluate", "review"],
    HandoffTo     = ["OrderAgent"])]
public class AnalysisTeam : IMentorTeam
{
    [TeamMember(
        Role         = "DataAnalyst",
        Tools        = [typeof(ReportTools)],
        Instructions = "Analyze quantitative data and KPIs.")]
    public object? Analyst { get; set; }

    [TeamMember(
        Role         = "RiskAnalyst",
        Instructions = "Evaluate risks and compliance. Reply APPROVED or REJECTED.")]
    public object? RiskAnalyst { get; set; }

    [TeamTerminationCondition]
    public bool ShouldTerminate(string lastMessage, string lastSpeaker)
        => lastSpeaker == "RiskAnalyst" &&
           (lastMessage.Contains("APPROVED") || lastMessage.Contains("REJECTED"));
}

Built-in AI tools

On top of the actions you declare, MentorAgent registers a handful of internal tools on the coordinator. You never declare or register these — they appear based on your configuration, and they are the reason the assistant can navigate, delegate and remember without you wiring anything.

Tool Appears when What it does
navigate_to Always (useful only with at least one [MentorPage]) The assistant asks the client to change page. Over SignalR this surfaces as the NavigationRequested event, over SSE as a navigation frame — your client decides how to route (React Router, Angular Router, MAUI Shell)
route_to_specialist At least one [MentorAgent], agent-source agent (e.g. Novior.MentorAgent.Declarative) or RemoteAgents entry route_to_specialist(request, specialist?): hands the turn to a Level-2 specialist or a remote A2A agent through the handoff workflow and returns the specialist's words, or NOT DELEGATED / DELEGATION FAILED. On a host with RemoteAgents the answer opens with a note saying whether it is this application's data or the remote system's
{action_name} A client registered UI actions for the current page One tool per registered action, injected per call, visible only while that page is active
remember / forget_all UseMemoryContext = true Saves a durable fact about the user, or clears their memory on request. With MemoryAutoCapture the writer runs after the turn instead and remember is omitted — except on Path B without a ClassifierChatClient, where remember is the writer
load_skill EnableSkills = true Loads the full instructions of a skill on demand, instead of paying for them on every turn
read_skill_resource EnableSkills = true + a skill has resource files Reads one resource file attached to a skill

What this means for a headless client: these tools do not execute anything in your frontend by themselves. navigate_to and the UI actions reach you as events on the wire; everything else runs server-side. A React client that ignores the NavigationRequested event simply will not navigate — the turn still completes normally.


Agent Skills

Progressive disclosure: the AI sees only the skill name and description (~100 tokens) until it decides to load the full instructions.

File-based (place in Skills/ folder):

Skills/
  refund-policy/
    SKILL.md         ← instructions + resources
    policy.md        ← attached resource (text files only — every file is read as UTF-8 text, so convert PDFs first)

Class-based:

[MentorSkill(Name = "shipping", Description = "Shipping and tracking operations",
    Instructions = "## Shipping\n- Standard delivery takes 3-5 working days...")]
// No Instructions / InstructionsFile → Skills/shipping/SKILL.md is used if it exists,
// otherwise the description alone.
public class ShippingSkill { }
options.EnableSkills = true;
options.SkillsFolder = "Skills";

Page context and UI actions

When using SignalR, the client sends a page context snapshot before each message. Server-side, the AI sees this context in its system prompt and can invoke UI actions that are executed client-side.

Client sends (before each message):

await connection.invoke('UpdatePageContext', {
    pageName: 'Orders',
    contextData: { activeFilter: 'Pending', visibleRows: '15' },   // values are strings (or null) — a number fails binding
    uiActions: [
        { name: 'highlight_row', description: 'Highlights a row', parameterHint: 'integer: row ID' },
        { name: 'open_modal',    description: 'Opens the create order modal' }
    ]
});
await connection.invoke('SendMessage', 'Highlight order 42', null);

Server invokes the UI action — client receives:

connection.on('UIActionRequested', (actionName, paramJson) => {
    if (actionName === 'highlight_row') highlightRow(JSON.parse(paramJson));
    if (actionName === 'open_modal')    openModal();
});

HITL — Confirming actions

When RequiresConfirmation = true, the server sends a ConfirmationRequired event and blocks until the user responds. The client must call POST /mentor/approve — not a hub method.

⚠️ Why not RespondToApproval via hub? ASP.NET Core SignalR processes hub messages sequentially per connection. While SendMessage is awaiting the confirmation TCS, the dispatcher cannot process any other hub message from the same connection. Calling RespondToApproval via hub would queue forever — a deadlock.

// 1. Receive the confirmation request
connection.on('ConfirmationRequired', (actionId, toolName, message) => {
    showConfirmDialog(message, {
        // Send the same credentials the hub connection used: approve (and cancel) are owner-checked —
        // an authenticated connection answered without a token gets 401, another user 403.
        onConfirm: () => fetch(`/mentor/approve?actionId=${actionId}&approved=true`,
                               { method: 'POST', headers: { Authorization: `Bearer ${myAccessToken}` } }),
        onCancel:  () => fetch(`/mentor/approve?actionId=${actionId}&approved=false`,
                               { method: 'POST', headers: { Authorization: `Bearer ${myAccessToken}` } })
    });
});

If the server is on a different origin, use the full URL: http://localhost:5169/mentor/approve?actionId=...&approved=true.

What POST /mentor/approve answers. 200: the waiting turn received the answer. 404: nothing is waiting for it any more — it was answered already, nobody answered it within ConfirmationTimeout, the turn was stopped, or the connection that raised it has closed (a connection's turn ends with it). 401 / 403: the caller is not the connection's principal. A 404 is not a fault: close the dialog.

What triggers a confirmation

Three sources, all server-side — the client only ever sees ConfirmationRequired:

builder.Services.AddMentorAgent(options =>
{
    // 1. Your own actions
    // [MentorAction(Description = "...", RequiresConfirmation = true)]

    // 2. Every tool of an MCP server
    options.McpServers = [
        new MentorMcpServer {
            Name = "filesystem", Command = "npx",
            Arguments = ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
            RequiresConfirmation = true,
        }
    ];

    // 3. By tool name — the way to gate tools you don't own
    options.RequiresApproval = tool => tool.StartsWith("delete_") || tool is "write_file";
});

Native (Agent Framework) approval mode

options.HitlMode = MentorHitlMode.Native;   // default: MentorHitlMode.Blocking

Blocking (default) parks the turn on a TaskCompletionSource — one agent run, streaming stays alive. Native uses the AF standard instead (ApprovalRequiredAIFunction → ToolApprovalRequestContent → ToolApprovalResponseContent): the model calls are the same, but each round of confirmations adds one agent run and the reply arrives in two segments, before and after the banner. In exchange the flow is interoperable with AF workflows and AF-native hosts.

Your clients need no changes. Both modes emit the same ConfirmationRequired(actionId, toolName, message) event and accept the same POST /mentor/approve reply, so React/Angular/Vue/WASM code written for one mode works unchanged with the other.

A declined action reads the same to the model in both modes — the action did not run, nothing was checked or changed, no other reason is to be given (BUG-115, rc.14). Stop pressed while a Native confirmation waits leaves that request open in the stored conversation; the next message on the same connection closes it, as not run, in the same request as the new question (BUG-116, rc.14 — before, every later turn failed until a reset). The same holds when the run that carried the user's answer fails or is stopped: a confirmed action may then have run, and is reported to the model as of unknown outcome, never run again.

Every level is gated

Level-2 specialists and Level-3 team members run their own function-calling loop inside the workflow, out of the coordinator middleware's reach, so their tools are wrapped in a GatedAIFunction — the gate travels with the tool. RequiredRoles, RequiresConfirmation, action feedback, NavigateTo, OnToolResult/OnException and per-tool metrics apply identically whether the coordinator calls a tool directly or delegates via route_to_specialist.

Nested tools always use the blocking confirmation flow, even under HitlMode = Native — an AF ToolApprovalRequestContent raised inside a workflow never surfaces to the orchestrator. Same event, same POST /mentor/approve, asked exactly once.


Register pages in the server project — the AI uses them to navigate autonomously and to understand what pages exist in the application.

// Server project — scanned via options.ScanAssemblies
// One class per page, placed anywhere in the assembly.

[MentorPage(Url = "/orders", Name = "Orders",
    Description = "Order list with filters and status management")]
public class OrdersPage { }

[MentorPage(Url = "/products", Name = "Products",
    Description = "Product catalog with stock and pricing",
    HasUIActions = true,     // waited on only on Blazor Server or in MAUI Blazor Hybrid — never for hub/SSE/A2A turns
    ReadyTimeout = 3000)]    // ms — default is 2000
public class ProductsPage { }

[MentorPage(Url = "/fulldemo", Name = "Full Demo",
    Description = "Complete feature demo — UIActions, HITL, navigation")]
public class FullDemoPage { }

⚠️ If a page is missing its [MentorPage] attribute, the AI will say the page does not exist — even if the route is valid. Always add the attribute for every page you want the AI to be aware of.

The AI calls navigate_to("/orders") automatically after relevant actions, or when the user asks to go to a page by name.

HasUIActions on a headless server. navigate_to waits for PageContext.SignalReady() only when the page runs in the same process as the turn — a Blazor Server circuit or a MAUI Blazor Hybrid app. A turn that arrives over the SignalR hub (WebAssembly, React, a MAUI app with its own native UI…), SSE or A2A has no page there, so nothing on the server could ever signal "ready": navigate_to does not wait (a Debug log records it) and returns at once. The new page's UI actions reach the model with the client's next UpdatePageContext snapshot — that is, on the user's next message. Setting HasUIActions costs nothing here, so one [MentorPage] class can serve both a Blazor Server host and headless clients.


Contextual memory

options.UseMemoryContext   = true;
options.MemoryContextCount = 10;    // max facts injected per session

// Reliable capture (default true): a dedicated post-turn LLM call extracts durable user facts
// (name, role, team, preferences) and stores them — no dependence on the model calling remember().
options.MemoryAutoCapture       = true;
// Inject only the memories semantically relevant to the message (identity/preference facts always
// kept) instead of the last N. Requires EmbeddingGenerator (see AI providers).
options.MemoryRelevanceFiltering = true;

How capture works — on Path A (a ChatClient is configured), and on Path B with a ClassifierChatClient, MemoryAutoCapture is the writer: after each user message a small extraction call saves facts reliably, even for phrasings like "Ciao, mi chiamo Antonio". The redundant remember tool is dropped then; forget_all stays. On Path B without a classifier it falls back to the remember tool, and the startup log says which of the two writes. Verify in the logs: [MentorAgent:Memory] Auto-capture saved 1 fact(s): user_name.

⚠️ The default store is in-memory (lost on restart, not shared across instances). For production register a persistent store before AddMentorAgent():

builder.Services.AddSingleton<IMentorMemoryStore, RedisMemoryStore>();

With authentication configured, memory is isolated per user (see Authentication).


RAG — Retrieval-Augmented Generation

builder.Services.AddScoped<IMentorRagSource, MyVectorDbSource>();

options.UseRag         = true;
options.RagResultCount = 3;
options.RagMinScore    = 0.7f;  // relevance threshold — nothing below it is injected; optional, see below
options.ShowRagSources = true;  // Blazor widget only: hub clients receive RagSourcesReady either way

Retrieval is semantic and always-on: your IMentorRagSource scores documents (e.g. cosine similarity) and RagMinScore filters them, so a pure command or greeting simply retrieves nothing above the threshold and injects nothing — no keyword pre-gate needed. When you do not set RagMinScore, the source's DefaultMinScore applies if it declares one; a source with cosine scores should, because the option's default (2) suits keyword scores.

Implement IMentorRagSource. To filter by the signed-in user, the page or the conversation, implement the SearchAsync(MentorRagRequest, CancellationToken) overload as well, and add Metadata to a result for the model to weigh. Metadata also reaches the client with the chips and may be repeated by the model, so nothing in it should be hidden from the user. RecentMessages holds the last three turns of the conversation: on the hub, the connection's; over SSE, where each /mentor/chat request is its own conversation, it is empty. The MentorAgent README, under RAG, has the details:

public class MyVectorDbSource : IMentorRagSource
{
    public float? DefaultMinScore => 0.6f;   // cosine similarity: 0.5 loose … 0.75 strict

    public async Task<IReadOnlyList<MentorRagResult>> SearchAsync(
        string query, int maxResults, CancellationToken ct)
    {
        var results = await _vectorDb.SearchAsync(query, maxResults);
        return results.Select(r => new MentorRagResult(
            Content:   r.Text,
            SourceUrl: r.Url,
            Title:     r.Title,
            Score:     r.Score)).ToList();
    }
}

Streaming responses

Every reply is streamed token by token — there is nothing to enable. Over SSE each chunk arrives as a data: line; over SignalR as a StreamingChunk event. Your client appends chunks to the current bubble and re-renders; the same CancellationToken runs through the whole pipeline (RAG, LLM call, tool execution), so stopping a turn really stops the work rather than just hiding the output.

// SSE — the whole streaming client
const res  = await fetch('/mentor/chat?message=' + encodeURIComponent(text));
const read = res.body!.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
  const { value, done } = await read.read();
  if (done) break;
  for (const line of value.split('\n')) {
    if (!line.startsWith('data: ')) continue;
    const evt = JSON.parse(line.slice(6));
    if (evt.type === 'chunk') appendToCurrentBubble(evt.text);
  }
}

Stopping a turn. Show a ■ Stop button while a turn is in flight. Over SignalR, call POST /mentor/cancel?connectionId=<connection.connectionId> (owner-checked: send the same credentials as the hub). Over SSE there is no connection id — abort the request instead; the server cancels the turn when the client goes away:

const ctrl = new AbortController();
fetch('/mentor/chat?message=' + encodeURIComponent(text), { signal: ctrl.signal });
stopButton.onclick = () => ctrl.abort();

Whatever was already streamed stays in the transcript.

⚠️ Cancel is an HTTP endpoint, not a hub method, and this is not a style choice: SignalR dispatches at most one hub invocation at a time per connection, so a CancelRequest hub call would sit in the queue behind the very SendMessage it is meant to abort and only run once that turn had already finished. The same reasoning applies to /mentor/approve.


Token & cost optimization

Novior.MentorAgent.Server minimizes the tokens sent on every request. Some optimizations are always on; two are opt-in.

Always on: a slim, cache-friendly system prompt (stable prefix, volatile data last) and per-call token logging:

[MentorAgent] Tokens — model: gpt-4.1, in: 1979, out: 62, call total: 2041, 812ms | session: 1979+62=2041 over 1 call(s)

Semantic tool filtering

Every tool is serialized as a JSON schema into each request — the biggest per-call cost when you have many tools (L1 actions + MCP). With filtering, only the tools semantically relevant to the message are sent; the AI still chooses freely among them. It requires EmbeddingGenerator (see AI providers) — without one, filtering is skipped and all tools are sent (with a warning); there is no keyword fallback.

options.EmbeddingGenerator  = new AzureOpenAIClient(endpoint, credential)
    .GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();

options.EnableToolFiltering = true;
options.ToolFilterMaxTools  = 12;    // max matched business tools (core tools always kept)
options.ToolFilterMinScore  = 0.35f; // cosine-similarity threshold (higher = stricter)
options.ToolFilterMinTools  = 0;     // default: the threshold alone (a trade-off - read the core README first)

Core tools (navigation, memory, routing, teams, skills, UI actions) are always kept. Only the actions the user may run are ranked: one with RequiredRoles they hold none of never travels. When nothing clears the threshold, the three best-ranked actions that need no confirmation and no role travel anyway — an MCP tool only when its server marks it readOnlyHint — because cosine is not calibrated and a question that needs data can score below a greeting (BUG-106); give every action that changes data RequiresConfirmation. When only actions that need confirmation clear it, the three best-ranked that do not travel with them (BUG-099). Log (Debug): Tool filtering: 10/47 tools sent (7 core + 3/40 matched + 0/0 hosted, minScore=0.35), preceded on such a turn by no action cleared minScore=0.35 (best: …); the 3 best-ranked that need no confirmation were sent: ….

History compaction

As a conversation grows it is re-sent on every call. Compaction shrinks it intelligently (collapse old tool results → keep the last N turns → hard token-budget backstop) instead of a blunt cut.

options.EnableCompaction         = true;
options.CompactionTokenThreshold = 4000;  // token budget that triggers compaction
options.CompactionMaxTurns       = 8;     // recent turns kept intact

Local history only (Path A / ChatClient: the built-in provider or your ChatHistoryProviderFactory / ChatHistoryProvider) — not installed with service-managed history (Foundry, Responses API with store). It changes what is sent to the model, never the stored conversation (BUG-104).

Semantic RAG & memory

Both inject context only when relevant, with no keyword heuristics: RAG via the vector search + RagMinScore (see RAG); memory via MemoryRelevanceFiltering (see Contextual memory). On Path A, MemoryAutoCapture also drops the remember tool schema from every call.


Middleware & extensibility

Robustness, tracing and an admin cost view are all configured on the server — a remote client sees the effects (a blocked message, a friendlier error, a redacted tool result) but configures none of it.

Every hook below is optional and defaults to today's behaviour, so you can adopt them one at a time:

// Built-in (no code): LLM safety checks on input and output.
options.EnableSafetyCheck       = true;   // moderate the user message
options.EnableOutputSafetyCheck = true;   // moderate the reply (buffers → no live streaming that turn)
options.SafetyCheckTimeout      = TimeSpan.FromSeconds(15);         // both checks (Zero = no limit)
options.SafetyCheckFailure      = MentorSafetyCheckFailure.Allow;  // no verdict: Allow = fail open, Block = refuse

// Custom hooks (replace/extend the built-ins):
options.InputGuardrail  = (msg, ct)   => Task.FromResult(IsSafe(msg));      // replaces EnableSafetyCheck
options.OutputGuardrail = (reply, ct) => Task.FromResult(IsSafeReply(reply)); // replaces EnableOutputSafetyCheck
options.OnToolResult    = (tool, result) => Truncate(result, maxChars: 2000); // transform a tool result
options.OnException     = ex => ex.Message.Contains("rate", StringComparison.OrdinalIgnoreCase)
    ? "The service is busy, please retry shortly." : null;
options.ConfigureChatClientPipeline = b => b.UseLogging();   // insert your own DelegatingChatClient / AF middleware

Why each hook exists. InputGuardrail / OutputGuardrail replace the built-in LLM moderation when you already own that decision (an existing classifier, a per-tenant policy) — note that the output guardrail must buffer the reply, so that turn loses live streaming. OnToolResult is the supported place to trim or redact what a tool returns before it reaches the model, which is where oversized payloads and unwanted personal data actually cost you tokens. OnException maps a raw provider error onto something a user can read. ConfigureChatClientPipeline is the escape hatch: any DelegatingChatClient or Agent Framework middleware of your own, inserted outermost. The pipeline is built with the host's IServiceProvider, so middleware that resolves services (UseLogging() takes its ILoggerFactory from DI) works without extra wiring. It is built once per coordinator, so once per SignalR connection here: a limit or a counter meant for the whole process belongs in a singleton you inject. Only the coordinator's own calls go through it: the auxiliary calls (classifier, safety checks, out-of-scope refusal, memory extraction, the lookup and action checks) use ClassifierChatClient ?? ChatClient, and specialists, team members and the handoff router use ChatClient. The built-in output check, like the input check, runs on ClassifierChatClient and is metered; SafetyCheckTimeout and SafetyCheckFailure apply to both.


Observability (OpenTelemetry)

Off by default. Turn it on and MentorAgent emits GenAI-convention traces and metrics that any OpenTelemetry backend already understands — you supply the exporter, the library never chooses one:

options.EnableObservability = true;
options.ObservabilityIncludeSensitiveData = builder.Environment.IsDevelopment(); // dev only

builder.Services.AddOpenTelemetry()
    .WithTracing(t => t.AddSource("MentorAgent").AddOtlpExporter())
    .WithMetrics(m => m.AddMeter("MentorAgent").AddOtlpExporter());

Emits GenAI-convention spans/metrics for the chat client (LLM) calls + MentorAgent per-turn/tool spans and counters under the source/meter named by ObservabilitySourceName (default "MentorAgent").

⚠️ ObservabilityIncludeSensitiveData adds prompts and completions to the spans. That is the whole conversation — user input included — landing in your tracing backend, so keep it to Development.


Token & cost dashboard

Admin-only, Azure-style: per-model breakdown (cheap / strong / embedding) with a model selector, temporal charts (tokens / requests / latency), and a per-model cost table. Supply prices, then read the snapshot from the endpoint (or IMentorMetrics.GetSnapshot() in-process):

options.ModelPricing = new Dictionary<string, ModelPrice>(StringComparer.OrdinalIgnoreCase)
{
    ["gpt-4.1"] = new ModelPrice(2.00m, 8.00m),                  // cheap chat
    ["o3"]      = new ModelPrice(2.00m, 8.00m),                  // strong routing
    ["text-embedding-3-small"] = new ModelPrice(0.02m, 0.00m),  // embedding
};
options.DashboardRole = "Admin";   // role required for the endpoint; "" leaves it open (dev only)

MapMentorAgentServer() exposes GET /mentor/admin/metrics returning a MentorMetricsSnapshot — now carrying the per-model breakdown (Models) and hourly time series (MetricsRetention, 7d) plus tokens, cost, deflection and top actions — gated by DashboardRole. Fetch it from your React/Vue admin UI, or bind <MentorDashboard Snapshot="..."/> in a WASM client to get the identical charts. Never expose it to end users. Cost appears only for priced models — key ModelPricing by the model id in the snapshot (for Azure OpenAI, your deployment name).

A non-empty DashboardRole requires ASP.NET Core authentication/authorization to be configured (app.UseAuthentication() / app.UseAuthorization()); otherwise the endpoint has authorization metadata with no middleware to enforce it. Use DashboardRole = "" only for local development.

Localization. <MentorDashboard/> is translated through MentorLocalizer (10 languages, English fallback). A WASM/Blazor client has no MentorAgent DI, so pass the language: <MentorDashboard Snapshot="..." Language="MentorLanguage.Italian" />.

Persistence (optional)

By default the snapshot is in-RAM and resets on restart. Register an IMentorMetricsStore before AddMentorAgent() for durability or an external source — the endpoint then returns await store.QueryAsync() ?? metrics.GetSnapshot(). The order matters: AddMentorAgent() only adds the seed-and-flush service when a store is already registered, so a store added after it is read by the endpoint but never persisted to:

// Local durability: seed on startup + timed/shutdown flush (JSON/DB).
builder.Services.AddSingleton<IMentorMetricsStore, FileMetricsStore>();

// External source: read the aggregate OpenTelemetry already exported (Prometheus / Azure Monitor).
builder.Services.AddHttpClient();
builder.Services.AddSingleton<IMentorMetricsStore, PrometheusMetricsStore>();   // or AzureMonitorMetricsStore

Working FileMetricsStore, PrometheusMetricsStore and AzureMonitorMetricsStore ship in the MentorAgentServer sample (Metrics/). The external readers query the same backend the OpenTelemetry export writes to — so persistence and multi-instance aggregation come from Observability, and the dashboard just reads it.

MetricsPersistenceInterval (default 30 seconds) controls how often the registered store is flushed; MetricsRetention (default 7 days) bounds how much of the hourly time series the snapshot carries.


Model routing

Cheap model for simple turns, strong model for complex ones — a real cost lever. Set StrongChatClient and pick a strategy (all avoid keyword matching on user text):

options.StrongChatClient = new AzureOpenAIClient(endpoint, credential).GetChatClient("gpt-4o").AsIChatClient();
options.RoutingStrategy  = MentorRoutingStrategy.Semantic;   // Semantic | Classifier | Cascade | Custom
  • Semantic — embeds the message, escalates on cosine similarity ≥ RoutingThreshold (0.35) to a "complex" exemplar. Multilingual, ~free; requires EmbeddingGenerator.
  • Classifier — a tiny LLM call labels the turn SIMPLE/COMPLEX.
  • Cascade — serves on cheap, judges completeness, re-runs on strong only if it fell short.
  • Custom — your predicate via UseStrongModelAsync (async, whole conversation) or legacy UseStrongModel.

Active only when StrongChatClient is set; the chosen model is logged; any routing failure falls back to cheap. The dashboard attributes tokens and cost per model, so cheap vs strong spend is broken out separately (a configured strong model shows up even before any turn escalates to it).

⚠️ If you also enable hosted tools, build StrongChatClient on a client that supports them too: the strong model receives the same tool list, so otherwise everything works until a turn escalates and that one fails with an unknown-parameter error. MentorAgent warns at startup when both are configured.


Structured outputs

When you need a typed object rather than prose — an extraction step, a form pre-fill, a value your own C# code will branch on — asking for JSON in the prompt and parsing the answer is unreliable. IMentorStructured derives a JSON schema from your type, constrains the model to it, and hands back the deserialized instance:

public record ExtractedOrder(string Customer, string[] Products, decimal Total);

// Inject IMentorStructured (registered by AddMentorAgent); the result is T? — null when nothing could be produced
var order = await structured.GenerateAsync<ExtractedOrder>(userText, "Extract the order details.");
Console.WriteLine(order.Total);      // already a decimal, no parsing

This is a separate call, not part of the chat turn: use it from your own endpoints and background jobs, where the caller is code rather than a person.


Rich responses (tables & lists)

EnableRichResponses (default true) nudges the coordinator to format structured data as Markdown tables / lists:

options.EnableRichResponses = true;   // false → terse plain-text replies

The Blazor/WASM widget renders this automatically (XSS-safe — model text is HTML-encoded before any tag is emitted). If you drive the SSE/hub from a custom React/Vue client, render the Markdown on your side (e.g. react-markdown + remark-gfm) to get the tables.

⚠️ If you render Markdown yourself, do not inject the model's output as raw HTML. Treat it as data: a Markdown renderer that escapes HTML (the default in react-markdown) is the safe choice. The Blazor widget HTML-encodes every piece of model text before emitting any tag, for this reason. Links deserve the same care: accept only http and https addresses, and open them with rel="noopener noreferrer", as the widget does.

This is Level 1 — Markdown the model writes. Cards are the level above.


Generative UI — cards (Level 2)

A tool returns a structure instead of a string, and the client renders it:

[MentorAction(Description = "Shows an order")]   // tool name: get_order (from the method name)
public MentorCard GetOrder(int id) => new("order")
{
    Title    = $"Order #{id}",
    Subtitle = "Mario Rossi",
    Accent   = MentorCardAccent.Success,
    Fields   = [ new("Status", "Shipped"), new("Total", "€ 2.599,98") ],
    Actions  = [ new("Open", MentorCardActionKind.Navigate, $"/orders/{id}") ],
};

A result is a card by its shape, not its .NET type: an object with a non-empty string kind, or an array of them — and only in the result of a method declared to return MentorCard or a sequence of them; any other result stays text (see Generative UI — cards in the MentorAgent README).

Cards reach remote clients two ways, both additive — a client that ignores them still receives the full answer as text:

Transport How it arrives
SignalR Cards hub event, payload MentorCard[]
SSE a {"type":"cards","cards":[…]} frame in the stream
data: {"type":"chunk","text":"Here are your orders."}
data: {"type":"cards","cards":[{"kind":"order","title":"Order #1001","subtitle":"Mario Rossi",
       "fields":[{"label":"Status","value":"Shipped"}],
       "actions":[{"label":"Open","kind":"navigate","value":"/orders/1001"}],
       "accent":"success"}]}
data: {"type":"completed"}

The SSE frame is camelCase with string enums — you read "kind": "navigate", not a number whose meaning would shift the day a member is inserted into the enum. The SignalR Cards event uses the hub's default JSON protocol: property names are camelCase too, but enums arrive as numbers (kind: 0 = SendMessage, 1 = Navigate, 2 = UIAction; accent: 0 = Default, 1 = Success, 2 = Warning, 3 = Danger, 4 = Info). Normalise before rendering if you read both transports:

const KINDS = ['sendMessage', 'navigate', 'uiAction'] as const;
const ACCENTS = ['default', 'success', 'warning', 'danger', 'info'] as const;
const norm = (c: any): Card => ({
  ...c,
  accent: typeof c.accent === 'number' ? ACCENTS[c.accent] : c.accent,
  actions: c.actions?.map((a: any) => ({ ...a, kind: typeof a.kind === 'number' ? KINDS[a.kind] : a.kind })),
});
conn.on('Cards', (cards: any[]) => cards.map(norm).forEach(render));

Rendering them in a custom client

type CardAction = { label: string; kind: 'sendMessage' | 'navigate' | 'uiAction'; value: string };
type Card = {
  kind: string; title?: string; subtitle?: string; imageUrl?: string;
  accent: 'default' | 'success' | 'warning' | 'danger' | 'info';
  fields?: { label: string; value?: string }[];
  actions?: CardAction[];
};

function onFrame(frame: any) {
  if (frame.type !== 'cards') return;
  for (const card of frame.cards as Card[]) render(card);
}

function onCardAction(a: CardAction) {
  if (a.kind === 'navigate')    router.push(a.value);
  if (a.kind === 'sendMessage') sendMessage(a.value);      // your existing send path
  if (a.kind === 'uiAction')    runLocalAction(a.value);   // your own client-side registry
}

You can act on value without validating it. Every card is built by server-side application code — the model only decides when the tool runs — so labels and URLs are yours, not something the conversation talked the assistant into producing. That property is what makes it reasonable to give a chat message buttons at all.

uiAction is the one kind a headless client must implement itself: there is no page-registered handler outside Blazor, so map the name onto whatever your frontend does.

Turning it off

options.EnableGenerativeCards = false;   // default true

The cards still go to the model, so the assistant keeps answering — it describes the data instead of your client showing it.


Declarative agents (YAML)

Level-2 specialists can be defined in files instead of C# classes, via the optional Novior.MentorAgent.Declarative package:

builder.Services.AddMentorAgentDeclarative(o => o.Directory = "Agents");
kind: Prompt
name: ShippingAgent
description: Answers questions about deliveries
instructions: |
  You handle shipping questions only. Never invent a tracking number.
tools:
  - kind: function
    name: get_all_orders

They join the same handoff graph as [MentorAgent] classes, so no protocol change: route_to_specialist reaches them and every transport works unchanged.

The tools section names tools your application already exposes, and they arrive already wrapped in MentorAgent's gate — RequiredRoles and human approval stay in force inside the agent's own function-calling loop. Only kind: function entries are kept, and every entry needs a kind. Entries of kind webSearch, codeInterpreter, fileSearch or mcp are removed from the agent with a warning (e.g. removed web_search ...). Web search, code interpreter, file search and MCP are configured by the host (options.HostedTools, options.McpServers). A definition file therefore cannot grant itself a capability the application does not have.

Treat definition files as code and load them only from deploy-time paths: a file chooses the model, writes the system instructions and names the callable tools.

For agents from a database or a configuration service, implement IMentorAgentSource and register it directly.


Onboarding tour

A short guided tour shown the first time a user opens the assistant, generated from this server's own surface — the pages registered with [MentorPage] and the descriptions of the assistant's tools that need no role. The tour is one for every visitor and GET /mentor/tour answers anyone, so it does not describe an action that needs a role, which the MCP server too describes only to a caller who holds it. A hardcoded tour is correct the day it is written and wrong three releases later; this one cannot describe a screen that no longer exists.

options.EnableOnboardingTour = true;

The steps are served as JSON:

curl http://localhost:5169/mentor/tour
[
  { "title": "Benvenuto su ShopFlow", "body": "Gestisci prodotti, ordini e clienti.",
    "url": null, "tryAsking": null },
  { "title": "Gestione Ordini", "body": "Visualizza, filtra e modifica lo stato degli ordini.",
    "url": "/data/orders", "tryAsking": "Quanti ordini sono in attesa?" }
]

tryAsking is the field that earns the feature: users rarely fail to find an in-app assistant, they fail to know what to ask it, and an example drawn from the app's own tools answers that better than any generic hint. Render it as a button that sends the text as an ordinary message.

Rendering it in a custom client

type TourStep = { title: string; body: string; url: string | null; tryAsking: string | null };

const steps: TourStep[] = await fetch('/mentor/tour').then(r => r.json());

// Show once per user — the server does not track who has seen it.
if (steps.length && !localStorage.getItem('tour.seen')) {
  showTour(steps, {
    onAsk:   (q: string) => sendMessage(q),        // your existing send path
    onOpen:  (url: string) => router.push(url),
    onClose: () => localStorage.setItem('tour.seen', '1'),
  });
}

Four things the endpoint guarantees, so your client does not have to:

  • Every url is a real registered page. A model asked to describe screens will invent a plausible one; generated URLs are checked against the registered pages and an unknown one is stripped, keeping the step's text. You can navigate to url without validating it.
  • Generated once per process. The response is identical for every user and is cached, so calling it on every page load costs nothing and cannot run up model spend.
  • Never empty for lack of a model. With no ChatClient, or on a generation failure, it falls back to a deterministic tour built from the same discovery data.
  • No per-user data. The payload is public screen names and capability descriptions — the same things the assistant states in conversation — which is why the endpoint is not role-gated, unlike /mentor/admin/metrics.

Seen-state is the client's job. The server has no idea who has already taken the tour, and deliberately so: it would mean per-user storage for a cosmetic flag. localStorage is enough.

No voice options here. Streaming text-to-speech, barge-in and hands-free (VoiceStreaming, VoiceBargeIn, VoiceHandsFree, VoiceRate) are browser behaviour and live in the client's options — Novior.MentorAgent.Blazor, or your own Web Speech API code. A headless server never speaks; it receives a transcript as an ordinary message. EnableVoiceInput / EnableVoiceOutput exist on MentorOptions only as a declaration of intent for the Blazor widget.


Evaluation & regression testing

MentorEvaluator wraps the Agent Framework's native evaluation (agent.EvaluateAsync + LocalEvaluator). Inject it in your tests to gate CI on token/quality regressions:

var report = await evaluator.RunAsync(
    [ new EvalCase("Hello", "A short greeting.", MaxTokens: 300) ],
    new MentorEvalOptions { SystemInstructions = mySystemPrompt, Judge = true, MinQuality = 0.6, MaxTotalTokens = 4000 });
report.ThrowIfFailed();

Plug native evaluators for production-grade quality & safety — Checks (e.g. EvalChecks.ToolCalledCheck(...)) and Evaluators (FoundryEvals, or MEAI quality/safety evaluators) both gate the report.


MCP — Model Context Protocol

MCP Client — consume external MCP servers

options.McpServers = [
    new MentorMcpServer {
        Name      = "filesystem",
        Command   = "npx",
        Arguments = ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
    }
];

MCP Server — expose actions as MCP tools

The MCP server is in this package (since 1.0.0-rc.15; up to rc.14 it came with MentorAgent, which no longer references ASP.NET Core so that MAUI builds for every target). AddMentorAgentServer() registers it. A host without the hub — a Blazor Server app — adds this package and registers it alone:

options.McpServerEnabled = true;

// Only without AddMentorAgentServer(), after AddMentorAgent():
builder.Services.AddMentorAgentMcp();
app.MapMentorAgentMcp();                                          // path: options.McpServerPath (default /mcp)
app.MapMentorAgentMcp("/my-mcp");                                 // a path passed here wins over options.McpServerPath
app.MapMentorAgentMcp(configure: e => e.RequireAuthorization());  // protect the endpoint

Every [MentorAction] method becomes an MCP tool except the gated ones. An MCP request has no signed-in user, so an action with RequiredRoles is withheld unless you tell MentorAgent who the caller is. An action that needs a confirmation (RequiresConfirmation or RequiresApproval) is withheld always, because nobody is on the far end of an MCP call to answer a dialog — from 1.0.0-rc.15 that includes every [MentorAction] declaring neither ReadOnly = true nor RequiresConfirmation (RequireConfirmationForWrites); declare RequiresConfirmation = false on a write that must stay published. A startup warning lists the withheld actions. The RequiresApproval predicate is asked again at every tools/list and tools/call, so the warning tells the state at startup: an action the predicate holds back later is no longer listed and, called, is refused as withheld (BUG-202). An IMentorActionPolicy (see Deciding before an action runs in the MentorAgent README) can also take a tool away from one caller: its IsAllowedAsync, asked with the caller McpCallerPrincipal resolves, decides each tools/list. An action declared ReadOnly = true (or through IsReadOnlyAction) is published with readOnlyHint, and some MCP clients skip their own confirmation for such a tool: declare it only on actions that really only read. Until you protect it, the endpoint is open. It carries no CORS policy (MCP clients are not browsers); a browser-based client needs the opt-in described in the MentorAgent README (MCP Server, Step 2).

builder.Services.AddHttpContextAccessor();
builder.Services.AddMentorAgent(options => options.McpServerEnabled = true);

// Publishes a role-gated action only to an authenticated caller that holds the role (IsInRole: a
// "roles" claim works once the identity's RoleClaimType names it — with JwtBearer and
// MapInboundClaims = false, set TokenValidationParameters.RoleClaimType = "roles"). Asked once per
// MCP request; never cached per caller (BUG-103).
builder.Services.AddOptions<MentorOptions>().Configure<IHttpContextAccessor>(
    (options, http) => options.McpCallerPrincipal = () => http.HttpContext?.User);

Every tools/call passes through OnToolResult, counts in the dashboard's top actions and against RateLimitPerUser (an allowance separate from chat messages, per NameIdentifier, sub or oid; a caller with none is counted by remote address — which AddHttpContextAccessor() above makes available — or else shares one allowance with every such caller), and writes one Information line with a fingerprint of the caller (BUG-107). Before the tool runs, the policies are asked with its arguments — IsAllowedAsync, then BeforeActionAsync, with the request's scope in MentorActionRequest.Services: a no answers "Unknown tool", as a missing role does (denied-policy in the log); a Stop returns an error with the policy's reason (blocked); a Confirm is refused, since nobody can confirm a call here (nobody-to-confirm); a policy that throws or runs out of ActionPolicyTimeout stops the call (policy-failed).

Connect Claude Desktop, VS Code Copilot, or any MCP client to /mcp.


A2A — Agent-to-Agent

A2A Consumer — call remote A2A agents

options.RemoteAgents = [
    new MentorRemoteAgent {
        Name         = "InventoryAgent",
        Description  = "Manages warehouse and inventory",
        AgentCardUrl = "https://inventory.example.com",
        Headers      = new Dictionary<string, string> {
            ["Authorization"] = $"Bearer {apiKey}"
        }
    }
];

A2A Server — expose as a federatable agent

The A2A agent is in this package too (since 1.0.0-rc.15, like the MCP server above). AddMentorAgentServer() registers it; a host without the hub calls builder.Services.AddMentorAgentA2A() after AddMentorAgent().

options.A2AServerEnabled = true;
options.A2AServerPath    = "/a2a";                          // where MapMentorAgentA2A() maps the task handler (default "/a2a")
options.A2AServerUrl     = "https://myapp.example.com/a2a"; // the FULL public URL, path included: the card publishes it verbatim
app.MapMentorAgentA2A();                                          // POST {A2AServerPath} + GET /.well-known/agent-card.json
app.MapMentorAgentA2A("/agent");                                  // a path passed here wins over A2AServerPath; A2AServerUrl must then end in /agent
app.MapMentorAgentA2A(configure: e => e.RequireAuthorization());  // protects the task handler AND the agent card

The agent card is always served at /.well-known/agent-card.json, whatever the path. With A2AServerUrl left null the card publishes the mapped path alone (e.g. /a2a), which only a caller on the same host can resolve — set it for any remote peer.

What a peer gets — the caller is a program, not a person:

  • A card it can follow. It advertises the JSONRPC binding at A2AServerUrl, which is why that must be the endpoint's full URL. It also lists your ungated [MentorAction]s of classes registered in DI as skills, one per tool name (actions with RequiredRoles, a confirmation or RequiresApproval are left out, and so is a name that one of them shares), the input/output modes and streaming.
  • An answer from tools, or a refusal. A served turn is told that no human is present: call the tool first, state only what a tool returned in this turn, and ask no follow-up questions. If it still ends without calling a tool that returns data (a navigation, a UI action, a memory write and a skill do not count), the reply is thrown away and the same request runs once more with a tool call required. If that second attempt returns no data either and the classifier model decides the reply states something about the application's records — a figure, or that an entry does not exist — the peer receives NOT GROUNDED: … instead. A policy read from a skill is returned as it is. There are never more than two attempts.
  • Its name for you, as data. A MentorAgent caller sends the name it uses for this host as message metadata (mentoragent.addressedAs), so a request like "how many products does InventoryAgent have?" is understood to be about this host. Only a single token of letters, digits and - _ . (64 characters at most) is accepted.
  • No onward delegation. A turn that arrives over A2A is not offered this host's own RemoteAgents (they are left out of its handoff workflow, its prompt and the route_to_specialist description). Your local specialists, declarative agents, teams and tools still serve it. Two hosts can therefore be each other's remote agent without looping, but chaining A → B → C through a MentorAgent host is not supported in 1.0. Point A at C directly.
  • A marked answer. The reply's message carries "aiGenerated": true in its metadata: the machine-readable marker of AI-generated text. The working message and the error messages do not.
  • A failed task, never an empty one. A turn that produced no reply is reported to the caller as failed ("this agent could not produce a reply to the request"), and a warning is logged on this host.
  • A cancel that stops. CancelTask stops the task's turn as Stop does: no action starts after it, nothing more is reported, and the task ends Canceled — streamed, with returnImmediately or waiting for the answer. If MapMentorAgentA2A() warns that it cannot wrap the A2A SDK's request handler, the call in progress of a cancelled task reads session-ended instead of stop.
  • Any host kind can serve. Headless servers and Blazor Server hosts both answer A2A tasks.

On the consuming side, when this host has RemoteAgents, every route_to_specialist result opens with its source: a local specialist's answer says no remote agent was contacted, a remote answer is marked as that system's data, and a remote agent that was named but never took part produces NOT <NAME>'S ANSWER plus a warning — so the assistant cannot present a local answer as the remote one. A turn that ends before a remote agent's answer, a Stop above all, cancels the task it delegated (CancelTask), so a MentorAgent peer starts no action once the cancel arrives (BUG-210).


Security

// AI-based safety check (detects prompt injection and jailbreaks)
options.EnableSafetyCheck = true;   // one classifier call per message: ~0.7–2 s on gpt-4.1 — point ClassifierChatClient at a small model to cut it

// Per-user rate limiting
options.RateLimitPerUser = 20;      // requires authentication for true per-user isolation

// Role-based actions — the tool name is the method name in snake_case: delete_record
// The caller must be authenticated and hold ANY ONE of the listed roles (not all of them).
[MentorAction(Description = "Deletes a record", RequiredRoles = ["Admin"])]
public Task DeleteRecordAsync(string id) => ...;

// Confirmation dialogs for destructive actions — tool name: cancel_order
[MentorAction(Description = "Cancels an order", RequiresConfirmation = true)]
public Task CancelOrderAsync(string id) => ...;

Authentication — per-user memory, rate limiting, and roles

AddMentorAgentServer() gives the MentorAgent core the caller of each request: the hub connection's user (HubCallerContext.User) for hub clients, HttpContext.User for /mentor/chat and for A2A tasks. MentorAgent reads it before the host's AuthenticationStateProvider, which it asks only inside a Blazor circuit. That makes per-user memory, per-user rate limiting and RequiredRoles work for any client — React, Vue, Angular, WASM, MAUI — and in a Blazor Web App too, whatever the order of AddRazorComponents() and AddMentorAgentServer(). Memory and the rate limit identify the user by its NameIdentifier claim, else sub, else oid, else its name. A key from any claim but NameIdentifier carries the claim's name (sub:…, name:…), so a name never matches another user's NameIdentifier.

A provider of your own is not asked on the hub, SSE or A2A. To add claims or roles to the user, use an IClaimsTransformation: ASP.NET Core applies it to HttpContext.User, and so to the hub connection's user. Your own code that resolves AuthenticationStateProvider outside a circuit still gets Blazor's provider, which throws there: that is Blazor's behaviour, not MentorAgent's.

Configure any ASP.NET Core authentication scheme on the server:

// Web API with JWT Bearer
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => { /* configure your JWT issuer */ });
builder.Services.AddAuthorization();

builder.Services.AddMentorAgent(options => { options.RateLimitPerUser = 20; });
builder.Services.AddMentorAgentServer();   // registers the identity bridge

var app = builder.Build();
app.UseCors("MentorAgentClients");   // only for clients on another origin, see CORS
app.UseAuthentication();
app.UseAuthorization();
app.MapMentorAgentServer(configureTransports: e => e.RequireAuthorization());

Call them in this order: CORS, then authentication, then authorization, then the endpoints (see CORS for what goes wrong otherwise).

The client must authenticate its connection — e.g. pass the access token to the SignalR hub:

// React / Angular / Vue / plain JS — you build the connection, so you set it directly
const connection = new signalR.HubConnectionBuilder()
    .withUrl('/mentor-hub', { accessTokenFactory: () => myAccessToken })
    .withAutomaticReconnect()
    .build();
// Blazor WASM (Novior.MentorAgent.Blazor) — the package builds the connection, so you supply the token
// through the options. Same mechanism, same query-string parameter on the wire.
builder.Services.AddMentorAgentBlazor(options =>
{
    options.HubUrl = "https://api.example.com/mentor-hub";
    options.AccessTokenProvider = () => Task.FromResult(tokenStore.Token);
});

With an absolute HubUrl like this one, the WASM client also sends confirmations and Stop (POST /mentor/approve, POST /mentor/cancel) to the hub's origin with Authorization: Bearer <token> from the same AccessTokenProvider, so a signed-in user's answers pass the owner check. With a relative HubUrl (client served by the same host) they go to the host HttpClient's BaseAddress; either way the WASM app must register an HttpClient: the standalone WebAssembly template registers one, the client project of a Blazor Web App does not.

A browser cannot send an Authorization header on a WebSocket or Server-Sent Events connection, so SignalR appends the token as ?access_token=…. With JWT bearer on the server, forward it in OnMessageReceived or the hub sees an anonymous caller and every gated action fails closed — correctly, and indistinguishably from a bug:

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;
    }
};

Without authentication configured, a caller is keyed by AnonymousIdentity. By default (PerSession), each hub connection and each SSE request gets its own memory and its own rate-limit counter. Shared puts them all on the single key "anonymous" (shared memory, one global rate limit). Either way, RequiredRoles actions fail closed (blocked). MentorAgent never replaces a Blazor host's AuthenticationStateProvider: it reads the request's caller first, as described above.


Attribute reference

[MentorAction] parameters

Parameter Description
Description Required. Natural language description used as the AI tool description
Category Published only as the skill's tag on the A2A agent card. Not used for any grouping
RequiresConfirmation Shows a confirmation banner before executing. Use for destructive or irreversible operations
RequiresConfirmationDeclared Read-only, not set in the attribute: true when the attribute writes RequiresConfirmation, true or false. It tells an action that declares nothing, which asks with RequireConfirmationForWrites on, from one that declares RequiresConfirmation = false, which does not: RequiresConfirmation reads false for both
ReadOnly Declares that the action only reads — it changes no data. MentorAgent takes your word for it: the action is published to MCP clients with readOnlyHint, which some clients take as leave to skip their own confirmation — so a write declared ReadOnly by mistake runs from them with nobody asked — and with RequireLookupForRecordQuestions on it is one the assistant may be required to call before answering a question about a value of your records. Ignored when the action also needs confirmation. Declare [Description] methods by name with options.IsReadOnlyAction
RequiredRoles ASP.NET Core identity roles: the caller needs any one of them (not all), must be authenticated. On the hub, /mentor/chat and A2A the user is the request's caller; in a Blazor circuit, the one the host's AuthenticationStateProvider reports. The model of the coordinator, and of a specialist, team member or agent source that holds the action, is offered it only in a turn whose user holds one of the roles. Empty = no role check
ProactiveHint Appended to the tool's description as Guidance: <hint> for the assistant's own tools (coordinator, specialists, team members) — not on the MCP server surface, and not used by semantic tool filtering
NavigateTo URL the AI navigates to automatically after successful execution

[MentorAgent] parameters

Parameter Required Description
Name ✅ Agent name — key in the Handoff graph and in the coordinator's system prompt
Description ✅ Capabilities description used by the coordinator to decide when to delegate
HandoffTo — Names of other [MentorAgent] this agent can hand off to (case-insensitive match)
Instructions — Custom system prompt, used exactly as written. Auto-generated from Name + Description when omitted — the generated prompt includes a grounding rule against stating facts no tool returned; a custom prompt replaces it, so include your own

[MentorTeam] parameters

Parameter Required Description
Name ✅ Team name
Description ✅ Description used by coordinator to decide when to activate
TriggerOn — Keywords that hint activation (not hard rules — the AI decides)
MaxIterations — Max turns before forced termination. Default: 10
HandoffTo — L2 agents to delegate execution to after team approves

[TeamMember] parameters

Parameter Required Description
Role ✅ Role name within the team (e.g. "DataAnalyst")
Instructions ✅ System prompt for this member
Tools — Read-only tool classes this member can call during discussion. They are the member's only: not also the coordinator's actions, nor on /mcp or the A2A card (a [MentorSkill] class keeps its coordinator tools). Discovered like any action: public instance methods declared on the class, with [MentorAction] or a non-empty [Description]

[MentorPage] parameters

Parameter Required Description
Url ✅ Page URL (e.g. "/orders")
Name ✅ Human-readable page name injected into the system prompt
Description — Optional feature description shown to the AI
HasUIActions — If true, navigate_to waits for PageContext.SignalReady() — only where the page runs in the turn's process (a Blazor Server circuit or a MAUI Blazor Hybrid app); hub, SSE and A2A turns never wait (see Page navigation). Default: false
ReadyTimeout — Timeout in ms for SignalReady() (Blazor Server circuits and MAUI Blazor Hybrid only). Default: 2000

[MentorSkill] parameters

Parameter Description
Name Unique skill name in kebab-case (e.g. "expense-report")
Description One-sentence description shown in the skill catalogue
InstructionsFile Path to a markdown file (relative to content root or absolute)
Instructions Inline markdown. Takes precedence over InstructionsFile

Persistent conversation history

By default, conversation history lives in memory and is lost on app restart — and every hub connection gets its own InMemoryChatHistoryProvider, built inside its coordinator.

⚠️ MentorOptions is a singleton. An object assigned to ChatHistoryProvider serves every connection and every user in the process, so a provider that keeps one list replays one user's conversation to all the others (BUG-055). For a multi-user server use ChatHistoryProviderFactory: it is invoked once per coordinator (once per hub connection) and receives that scope's IServiceProvider, so the provider can partition its storage by the caller:

// ✅ isolated in-memory history, one per connection
options.ChatHistoryProviderFactory = _ => new InMemoryChatHistoryProvider();

// ✅ shared backing store, partitioned by the caller
options.ChatHistoryProviderFactory = sp =>
    new CosmosChatHistoryProvider(cosmosClient, "my-db", "conversations",
        partitionKey: sp.GetRequiredService<IUserContext>().UserId);

Keep the instance property only for a single-user host, or a provider that partitions its own storage internally. Setting both stops the host when it starts.

Keep the transcript in the session, not in a field. The provider is built once per coordinator and kept for the whole hub connection — across new conversations (the hub's ResetSession, the widget's reset button) and across sessions restored through /mentor/session. A provider that stores messages in a field carries the previous conversation into the one the user has just started (BUG-102). Store them in the session's StateBag, as InMemoryChatHistoryProvider does; the Agent Framework's ProviderSessionState<T> does the bookkeeping:

public sealed class MyHistoryProvider : ChatHistoryProvider
{
    private readonly ProviderSessionState<List<ChatMessage>> _transcript =
        new(_ => [], nameof(MyHistoryProvider), AIJsonUtilities.DefaultOptions);

    protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
        InvokingContext context, CancellationToken ct = default)
        => ValueTask.FromResult<IEnumerable<ChatMessage>>([.. _transcript.GetOrInitializeState(context.Session)]);

    protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken ct = default)
    {
        var history = _transcript.GetOrInitializeState(context.Session);
        history.AddRange(context.RequestMessages);
        history.AddRange(context.ResponseMessages ?? []);
        _transcript.SaveState(context.Session, history);
        return ValueTask.CompletedTask;
    }
}

A provider backed by an external store does the same with the key of its rows: keep it in the session (a conversation id in the StateBag), not in the provider.

Instance form (single-user hosts, or a provider that partitions internally):

// CosmosDB
options.ChatHistoryProvider = new CosmosChatHistoryProvider(cosmosClient, "my-db", "conversations");

// Custom (implement ChatHistoryProvider from Microsoft Agent Framework)
options.ChatHistoryProvider = new MyRedisChatHistoryProvider(redisConnection);

The connection a provider like this holds is shared; the rows it reads and writes are keyed by the session, as above — never by a field of the provider.


Session serialize and restore

Remote clients: use the session endpoints. The conversation lives in the DI scope of the client's hub connection. A controller or minimal API resolves the request's scope — a different, empty orchestrator — so an IMentorOrchestrator injected there always serializes nothing. MapMentorAgentServer() maps the transport that reaches the right scope:

Endpoint Answers
GET /mentor/session?connectionId=<id> 200 + snapshot · 204 no conversation yet (do not store it over a good snapshot) · 404 unknown connection
POST /mentor/session?connectionId=<id> (body: the snapshot) 200 restored · 400 not a snapshot this server issued for this user, edited, or unusable by this host (live conversation untouched) · 415 content type not JSON (a body that does not parse is a 400) · 404 unknown connection

Both are owner-checked like /mentor/approve (401 / 403 for a caller that is not the connection's principal, before the body is read): send the same credentials the hub connection used.

The snapshot is opaque (BUG-110). Until rc.13 it was the conversation in clear: a client could read it, edit it — an edited transcript restored as history the model trusts — and restore another user's snapshot into its own connection. Since rc.14 it is protected with ASP.NET Core Data Protection under a purpose that names the connection's user: store it verbatim, as text. It restores only for that user, on this application's key ring. On a host without authentication every connection's user is the same anonymous one, so there a snapshot restores into any connection of the application: it still cannot be read or edited, but binding it to a person needs signed-in users. Snapshots saved before rc.14 are refused (400); save the conversation again. A snapshot is also refused when this host keeps its history another way (on the service vs locally, or under another history provider) — it would fail every turn after it, or restore empty.

Key ring. To restore after a restart or on another instance, persist and share the Data Protection keys, as you already do for authentication cookies: builder.Services.AddDataProtection().PersistKeysToFileSystem(...) (or Azure Blob Storage / Key Vault, Redis). Windows and Azure App Service persist them by default; a container without a volume does not. The client-side save/restore example is under What Novior.MentorAgent.Server exposes.

A 404 straight after connecting can mean the request overtook the hub's OnConnectedAsync: restore after the first hub call, or retry once.

In-process code that already runs inside the conversation's scope (a hub filter, a Blazor Server component) can call the orchestrator directly — IMentorOrchestrator is registered by AddMentorAgent(). The API, shown on a controller for brevity (a controller only sees the conversation if it shares that scope — see below):

// Inject in a service or controller. ISnapshotStore is yours: Redis, a database table...
public class SessionController(IMentorOrchestrator mentor, ISnapshotStore store) : ControllerBase
{
    [HttpPost("session/save")]
    public async Task<IActionResult> Save()
    {
        // This snapshot is the conversation IN CLEAR: keep it on the server, keyed by the signed-in
        // user. Never hand it to the client — it could read it, edit it (history the model trusts),
        // or send back someone else's: what /mentor/session protects against.
        // null means "nothing to save yet" — do not store it over a good snapshot.
        JsonElement? snapshot = await mentor.SerializeSessionAsync();
        if (snapshot is null) return NoContent();
        await store.SaveAsync(User.FindFirstValue(ClaimTypes.NameIdentifier)!, snapshot.Value);
        return Ok();
    }

    [HttpPost("session/restore")]
    public async Task<IActionResult> Restore()
    {
        // Restore on reconnect (e.g. after server restart). A snapshot that is not ours — a stale
        // entry from an older deployment — is refused here, and the live conversation is untouched.
        if (await store.LoadAsync(User.FindFirstValue(ClaimTypes.NameIdentifier)!) is not { } snapshot)
            return NoContent();
        try { await mentor.RestoreSessionAsync(snapshot); }
        catch (ArgumentException ex) { return BadRequest(ex.Message); }
        return Ok();
    }
}

Remember that the orchestrator is scoped: on Blazor Server it belongs to the circuit, so a controller resolves a different scope from the one the widget is using. Save and restore from the same scope that holds the conversation — a hub method, or a component — or the snapshot will be of an empty session. The full lifetime table — which service is per-circuit and which is a singleton — is in the Novior.MentorAgent README, under Blazor Server vs Blazor WASM.

Over the hub the scope is the connection, so a reconnect is a new scope and therefore a new conversation. That is deliberate — it is what keeps two browsers apart — and it is exactly what SerializeSessionAsync / RestoreSessionAsync (over HTTP: GET/POST /mentor/session) exist to bridge. Snapshot while the connection is alive — after each completed turn — because on disconnect the server disposes the connection's scope and the old id answers 404. Restore on the new connection id after withAutomaticReconnect() brings the connection back, or the user silently starts over.

The lower-level IMentorSessionManager is registered too, but every method on it takes the coordinator AIAgent, which is deliberately not handed out to application code — running it directly would bypass the guardrails, the rate limiter, the role gate and the confirmation gate. Use the orchestrator.

The widget's reset call (ResetSession hub method) starts a new AgentSession on the same connection. The history provider is kept, so a provider that stores the transcript in a field — rather than in the session — carries the old conversation into the new one (see Persistent conversation history).


All configuration options

The AddMentorAgent callback runs twice: once on a snapshot, to decide what to register, then on the real options. EnableObservability, ObservabilitySourceName, McpServerEnabled, A2AServerEnabled, and AppName as the MCP server's name are read from that snapshot, so set them in the callback: a Configure<MentorOptions> registered after AddMentorAgent does not reach them. The A2A agent card is built from the real options, when MapMentorAgentA2A() runs. The options are checked when the host starts: an invalid configuration stops it, with the reason, before the first connection.

Core

Option Type Default Description
AppName string (required) Application name for the system prompt
AppDescription string "" Domain description for richer AI context
ChatClient IChatClient? null AI provider (recommended)
Agent AIAgent? null Pre-built AI agent (alternative). Two calls with one id in a response of your agent's model are not made unique: your agent runs them before MentorAgent's clients see them (BUG-225).
EmbeddingGenerator IEmbeddingGenerator<string, Embedding<float>>? null Optional embedding model — enables semantic tool filtering
ScanAssemblies Assembly[] (required) Assemblies to scan for agents, actions, pages
Language MentorLanguage English Language for AI responses, and for the instructions MentorAgent writes that the model could echo (see What follows Language in the MentorAgent README). Read again on every turn; a client can give a turn its own language (SendUserMessage, language on /mentor/chat)
MentorshipLevel MentorshipLevel Standard AI proactivity: Minimal / Standard / Proactive
ProactiveNextSteps bool false With Proactive, close answers with suggested next steps; off by default, since the model offered steps the application does not have (BUG-074)
EnableSuggestions bool true Shows the starter suggestion chips in the Blazor widget's welcome panel. Nothing is generated or sent to headless clients — no hub event or SSE frame carries suggestions — so it has no effect on a React/Angular/Vue client or on a MAUI app with its own native UI. A MAUI Blazor Hybrid app runs the Blazor widget, and shows them
EnableActionFeedback bool true Emit ActionExecuting / ActionCompleted (hub events) while one of your function tools runs; false removes those. Hosted-tool lines such as "Searching the web…" are controlled separately by ShowHostedToolActivity
EnableVoiceInput bool false Declares that voice input is expected. A headless client implements capture itself (Web Speech API, MAUI speech-to-text) and sends the transcript as an ordinary message
EnableVoiceOutput bool false Declares that replies are meant to be spoken. Synthesis happens on the client — the server streams the same text either way
EnableOnboardingTour bool false Tells the Blazor widget to show the onboarding tour. GET /mentor/tour is mapped by MapMentorAgentServer() regardless of this flag — a headless client that fetches it gets the steps (generated once per process, one model call on the first request; [] when no [MentorPage] exists), so decide in the client whether to show them
EnableGenerativeCards bool true Forward MentorCards returned by a tool to clients as cards. false sends the data to the model as text instead
EnableRichResponses bool true Instructs the coordinator to format answers as Markdown: tables for comparative data, lists for enumerations (generative UI Level 1). The Blazor widget renders Markdown safely; a client of your own receives it as text and renders it itself. false asks for terse plain text

Not listed here on purpose. MentorOptions also carries Theme, Position, PrimaryColor, BotName, WelcomeMessage, InputPlaceholder, AvatarUrl, ShowAiDisclosure, AiDisclosureMessage and the playback options VoiceStreaming, VoiceBargeIn, VoiceHandsFree, VoiceRate. They style or drive the Blazor widget and have no effect on a headless backend — a React/Angular/Vue client, or a MAUI app with its own native UI, renders its own UI and, for the voice ones, does its own speech work. Set them only if you also serve <ChatWidget /> from a Blazor project; for Novior.MentorAgent.Blazor clients they live in MentorAgentBlazorOptions on the client side instead.

Multimodal image input

Option Type Default Description
EnableImageInput bool false Accept image attachments on SendMessage / POST /mentor/chat (see Multimodal image input). Requires a vision-capable ChatClient
MaxImageBytes int 4194304 Max decoded size per inline image (4 MB)
MaxImagesPerMessage int 4 Max images accepted per user turn
AllowedImageTypes IReadOnlyList<string> png, jpeg, gif, webp MIME allow-list; anything else is dropped with a warning

Hosted tools

Option Type Default Description
HostedTools MentorHostedTools None Provider-hosted tools: WebSearch, CodeInterpreter, FileSearch, ImageGeneration, HostedMcp (flags) — see Hosted tools
FileSearchVectorStoreIds IReadOnlyList<string>? null Vector stores searched by FileSearch. Required when it is on — without ids the tool is skipped (fail-closed)
FileSearchMaxResults int? null Upper bound on file-search matches
HostedImageModel string? null Model used by ImageGeneration (e.g. gpt-image-1-mini), read by providers that take it from the tool payload (OpenAI). On Azure OpenAI it is not enough on its own: the image deployment must also travel as the x-ms-oai-image-generation-deployment request header — add it as a pipeline policy where you build the AzureOpenAIClient, or every image turn fails with "imagegen deployment must be provided through header"
HostedImageSize string? null Generated image size as WIDTHxHEIGHT (e.g. "1024x1024"). The cost knob — a larger image is billed more. An unparsable value is ignored with a warning
HostedMcpServers IReadOnlyList<MentorHostedMcpServer>? null Remote MCP servers the provider connects to, used by HostedMcp. Required when it is on (fail-closed). Per server: Name, Url, Description, AllowedTools, RequireApproval (default true), AlwaysRequireApprovalTools / NeverRequireApprovalTools, Headers
ShowHostedToolsStatus bool false Amber badge in the Blazor widget header listing the active hosted tools
FilterHostedTools bool true Score each hosted tool against the user's message and declare it only when relevant, instead of on every turn. Needs EnableToolFiltering + EmbeddingGenerator; without them nothing is filtered and a warning is logged (fail-open)
HostedToolFilterMinScore float? null → 0.15 Threshold for hosted tools only — deliberately lower than ToolFilterMinScore: their scores run on a different scale, and this is a coarse pre-cut now that HostedToolDomainCheck decides. Raising it makes the verdict flip between rewordings of the same request. Scores logged at Debug
HostedToolDomainCheck bool false Ask a small model whether the message concerns this application before a hosted tool runs. Different question from FilterHostedTools: "draw me a dog" is a genuine image request and nonsense for a shop backend. Once per turn, only when a hosted tool already passed relevance, so ordinary turns cost nothing. Fails open. When it withholds a tool the model is told so in that same request (via ChatOptions.Instructions, which is per-request and never stored in service-managed history) and instructed to refuse plainly — without it, a model whose prompt still advertises the capability fills the gap by inventing a result
HostedToolDomainScope string? null The scope the classifier judges against; null derives it from AppName + AppDescription + page names, plus the enabled hosted capabilities and the rule that they do not widen the subject
HostedToolDomainClassifier Func<string, CancellationToken, Task<bool>>? null Replaces the model call with your own decision (true = in scope) — an existing intent service, per-user policy, or to make the check free
MaxHostedToolCallsPerSession int 0 Hard cap per hub connection / SSE session; beyond it hosted tools stop being declared. 0 = no cap. Scoring lowers the frequency, only a counter bounds the worst case. ⚠️ The counter lives in the orchestrator scope, so over SignalR it spans the whole connection, while a POST /mentor/chat call is its own session — a stateless HTTP client gets a per-request cap, not a per-user one
ShowHostedToolActivity bool true Emit live hosted-tool activity to clients: ActionExecuting / ActionCompleted, RagSourcesReady for the pages a web search used and the documents a file search matched, GeneratedImages for generated images

Token & cost optimization

Option Type Default Description
EnableToolFiltering bool false Send only the tools semantically relevant to the message. Requires EmbeddingGenerator; without it, all tools are sent
ToolFilterMaxTools int 12 Max matched business tools (core tools always kept)
ToolFilterMinTools int 0 Once one action clears ToolFilterMinScore, at least this many best-ranked actions are sent, whatever their score (up to ToolFilterMaxTools). 0 = the threshold alone. A trade-off, not a fix: it can hand the model a read action it needed, or a wrong one it picks instead
ToolFilterMinScore float 0.35 Minimum cosine similarity (0–1) for a tool to be relevant
RequireLookupForRecordQuestions bool false Opt-in. When a classifier says a message asks for a value of your records, the first model call must call a declared read-only action (or hand over to a specialist): with semantic tool filtering, one the filter selected; without it, any declared read the user may run. One classifier call per turn whose selection holds a read — without the filter, nearly every turn. Needs declared reads. See Semantic tool filtering
RequireActionForChangeRequests bool false Opt-in. When a classifier says a message asks to carry out one of your confirmation-gated actions and gives every input it needs, the first model call must call one of those actions the filter selected (or hand over to a specialist), so the banner comes first; when it asks for a page, navigate_to. A request that leaves an input out is not forced. One classifier call per turn, shared with RequireLookupForRecordQuestions. Unlike that option, needs EnableToolFiltering and an EmbeddingGenerator, and RequireConfirmation for actions. See Semantic tool filtering
EnableCompaction bool false Compact long conversation history before each call — what is sent, never what is stored (BUG-104). Locally kept history only (Path A); not installed with UseServiceManagedHistory
CompactionTokenThreshold int 4000 Token budget that triggers compaction
CompactionMaxTurns int 8 Recent turns kept intact

EmbeddingGenerator also powers semantic memory (MemoryRelevanceFiltering, see Memory). RAG relevance is handled by the vector search + RagMinScore (see RAG) — no keyword gating.

Also: AddMentorAgentServer() builds the coordinator once per SignalR connection (not per message), so external MCP servers are connected once and the conversation session persists across messages.

Middleware, observability & dashboard

Option Type Default Description
InputGuardrail Func<string,CancellationToken,Task<bool>>? null Custom input guardrail (true = safe); replaces the built-in check
OutputGuardrail Func<string,CancellationToken,Task<bool>>? null Moderate the completed reply (true = safe); buffers the reply then reveals it (no live streaming that turn)
OnToolResult Func<string,object?,object?>? null Transform/redact a tool result before it returns to the model. A hook that throws withholds the result: the model is told the action ran and not to run it again
OnException Func<Exception,string?>? null Map an exception to a user-facing message (null → default). For a tool that throws, the chat never shows the exception's own text while the hook is set: null shows the localized "The action could not be completed." The model gets the hook's message, else the exception's, and may repeat it in its reply: return a message for any exception whose text must not reach the user
ConfigureChatClientPipeline Func<ChatClientBuilder,ChatClientBuilder>? null Insert custom middleware into the Path A pipeline (outermost). Built once per coordinator, so once per SignalR connection; only the coordinator's own calls go through it — see Middleware & extensibility
EnableObservability bool false Emit OpenTelemetry traces/metrics + MentorAgent spans/counters
ObservabilityIncludeSensitiveData bool false Include prompt/response content — Development only
ObservabilitySourceName string "MentorAgent" ActivitySource/Meter name to .AddSource()/.AddMeter()
ModelPricing IReadOnlyDictionary<string,ModelPrice>? null Per-model prices for the dashboard cost estimate (none built in)
DashboardRole string "Admin" Role required for GET /mentor/admin/metrics ("" = open, dev only)
MetricsPersistenceInterval TimeSpan 30s How often a registered IMentorMetricsStore is flushed; also flushes on graceful shutdown. Without a store the snapshot is in-RAM and resets on restart
MetricsRetention TimeSpan 7d How much of the hourly time series the snapshot carries — the window the dashboard charts can show
StrongChatClient IChatClient? null Strong model to escalate to (ChatClient is the cheap default). Routing active only when set
RoutingStrategy MentorRoutingStrategy Custom Semantic / Classifier / Cascade / Custom — how the cheap↔strong decision is made
UseStrongModelAsync Func<IReadOnlyList<ChatMessage>,CancellationToken,Task<bool>>? null Custom: async, context-aware router (precedence over UseStrongModel)
UseStrongModel Func<string,bool>? null Custom: legacy sync predicate on the latest user message
RoutingComplexExemplars IReadOnlyList<string>? null Semantic: example "complex" turns (null → built-in set)
RoutingThreshold float 0.35 Semantic: cosine floor to escalate
RoutingClassifierClient IChatClient? null Classifier/Cascade: dedicated judge client (defaults to cheap ChatClient)
ClassifierChatClient IChatClient? null Client for MentorAgent's own one-word decisions (input and output safety, hosted-tool scope, the request check of RequireLookupForRecordQuestions and RequireActionForChangeRequests, fact extraction). A small deployment here cuts the wait before every reply; defaults to ChatClient. On Path B (a pre-built Agent) it is the only client MentorAgent can call on its own: without it the safety checks are skipped

Also available as services (resolve from DI): IMentorStructured — typed GenerateAsync<T>, see Structured outputs — MentorEvaluator, the token/quality regression harness, see Evaluation & regression testing — and IMentorTour, the onboarding tour source, which you can replace with your own registration to script the tour by hand.

Memory

Option Type Default Description
UseMemoryContext bool false Enable automatic user memory
AnonymousIdentity MentorAnonymousIdentity PerSession Who a signed-out caller is: PerSession = one key per hub connection and one per /mentor/chat request (no memory or rate-limit sharing between anonymous callers, and none carried from one SSE request to the next); Shared = the previous single "anonymous" key, for single-user hosts
MemoryContextCount int 10 Max memories injected into the prompt on each call (last N, or the most relevant N with MemoryRelevanceFiltering)
MemoryRelevanceFiltering bool false Inject only the memories semantically relevant to the current message (embedding cosine; identity/preference facts always kept) instead of the last N — saves tokens. Requires EmbeddingGenerator; without it, falls back to last-N
MemoryAutoCapture bool true The reliable memory writer: a post-turn extraction saves durable user facts instead of relying on the model to call remember. With a ChatClient, or on Path B with a ClassifierChatClient, it is the only writer — the redundant remember tool + prompt are dropped (saves tokens); on Path B without a classifier it falls back to the remember tool. forget_all always kept. One small model call per user message; set false to opt out

Agent Skills

Option Type Default Description
EnableSkills bool false Enable skill discovery and load_skill / read_skill_resource tools
SkillsFolder string "Skills" Folder to scan for file-based skills (SKILL.md)
SkillSources IList<AgentSkillsSource> empty Extra sources using the Agent Framework's own abstraction — a database, a remote catalogue, or Microsoft's AgentFileSkillsSource. Your own skills win a name collision
SkillFilter Func<AgentSkill, bool>? null Keeps or drops a skill from SkillSources. Never applies to skills MentorAgent discovered itself
SkillsRefreshInterval TimeSpan? null Set: the composed SkillSources (wrapped in the Agent Framework's CachingAgentSkillsSource) are held once per process and shared by every session; the list is re-fetched when a session is built after the interval has passed. null: every new session (circuit, hub connection, SSE request) re-reads the sources when its coordinator is built and keeps that list for its lifetime — nothing is cached for the process. Worth setting for a remote or expensive source

RAG

Option Type Default Description
UseRag bool false Enable RAG. Requires a registered IMentorRagSource
RagResultCount int 5 Number of documents retrieved per query
RagMinScore float 2 Minimum relevance score. 0 = no filtering. For keyword search: 2 ≈ two content matches. For vector/cosine similarity: use 0.5–0.75. Not set: the source's DefaultMinScore when it declares one
RagSystemPromptTemplate string "Use the following documents to answer:\n{documents}" Prompt template. {documents} is the placeholder
ShowRagSources bool false Show citation chips in widget

RAG is fully semantic: vector search + RagMinScore inject nothing on pure commands, so no keyword gating is needed.

MCP

Option Type Default Description
McpServers MentorMcpServer[]? null External MCP servers as L1 tools
MentorMcpServer.Shared bool? null One connection per process (true) or per session (false). Unset: URL servers shared, command servers per session
McpServerEnabled bool false Expose as MCP server. Also call app.MapMentorAgentMcp(); AddMentorAgentServer() registers the server (without it: AddMentorAgentMcp())
McpServerPath string "/mcp" Path app.MapMentorAgentMcp() maps when called without a path; a path passed to the method wins
McpCallerPrincipal Func<ClaimsPrincipal?>? null Resolves who is calling the MCP server, so role-gated actions can be published to callers entitled to them. Default null withholds every gated action — the safe default. A null principal, an unauthenticated one, or a resolver that throws all withhold too. Confirmation gates are never satisfied by it: RequiresConfirmation and RequiresApproval mean "ask a human", and there is none on the far end of an MCP call
ShowMcpStatus bool false Show MCP status badge in widget

A2A

Option Type Default Description
RemoteAgents MentorRemoteAgent[]? null Remote A2A agents in the Handoff workflow
A2AServerEnabled bool false Expose as A2A agent. Also call app.MapMentorAgentA2A(); AddMentorAgentServer() registers the agent (without it: AddMentorAgentA2A())
A2AServerPath string "/a2a" Path app.MapMentorAgentA2A() maps the task handler at when called without a path; a path passed to the method wins. The card stays at /.well-known/agent-card.json
A2AServerUrl string? null Full public URL of this agent's A2A endpoint, path included (e.g. https://myapp.example.com/a2a) — published verbatim in the agent card. null = the card publishes the mapped path only (same-host callers). Required when used as remote by other agents
AgentCard AgentCardInfo? null A2A Agent Card metadata
ShowA2AStatus bool false Show A2A status badge in widget

Security & Limits

Option Type Default Description
EnableSafetyCheck bool false AI-based prompt injection detection. Runs on ClassifierChatClient, or on ChatClient when it is not set: on Path B without one it is skipped, with a warning when the coordinator is built. A custom InputGuardrail replaces it
EnableOutputSafetyCheck bool false Built-in LLM check on the reply: blocks one that leaks system instructions or secrets, discloses someone's personal data, or contains harmful content. Buffers the reply for that turn (no live streaming). Runs on ClassifierChatClient, or on ChatClient when it is not set, is bounded by SafetyCheckTimeout / SafetyCheckFailure, and a custom OutputGuardrail takes precedence. Requires a ChatClient or a ClassifierChatClient: on Path B without one it is skipped, with a warning when the coordinator is built
SafetyCheckTimeout TimeSpan 00:00:15 Time limit for the input/output safety checks (built-in or custom); Zero = no limit, Stop still cancels
SafetyCheckFailure MentorSafetyCheckFailure Allow A check with no verdict (timeout, error): Allow = fail open with a warning, Block = refuse the message / withhold the reply
RefuseOutOfScope bool false Refuse a message that plainly has nothing to do with the application instead of answering it; defaults to answering when unsure (BUG-105). A short reply ("yes", a choice the assistant offered) is judged with the end of the assistant's last reply, in the same call; the safety check still judges the message alone (BUG-223). With the safety check on it rides in the same classifier call (a longer prompt: the question and the scope); alone it costs one small call per turn, and a turn that is about to declare a hosted tool still asks the tool gate its own question. Counted in GuardrailBlockCount
WarmUpAtStartup bool false Build one coordinator at start-up (catalogue embeddings, shared MCP sessions, agent cards) so the first connection does not pay for them. Runs once the host has started; a failed warm-up never fails the boot
MaxMessageLength int 4000 Max message length (0 = unlimited)
RateLimitPerUser int 0 Max messages per user within RateLimitWindowSecs (0 = disabled). Signed-out callers are counted per connection under the default AnonymousIdentity = PerSession. MCP tools/call has its own allowance with the same limit, per NameIdentifier, sub or oid; a caller with none is counted by remote address when IHttpContextAccessor is registered, otherwise all such callers share one
RateLimitWindowSecs int 60 Rate limiting window in seconds
RequireConfirmation bool true On/off for the confirmation dialogs of your actions, MCP-client servers and RequiresApproval. A hosted MCP server's approvals (RequireApproval, AlwaysRequireApprovalTools) still show the banner
RequireConfirmationForWrites bool true A [MentorAction] declaring neither ReadOnly = true nor RequiresConfirmation asks for confirmation, so it is also withheld from /mcp and the A2A card. [Description]-only methods are not covered. false = the rc.14 behaviour
HitlMode MentorHitlMode Blocking Blocking (MentorAgent's flow) or Native (AF ApprovalRequiredAIFunction). Same client protocol either way — see HITL. A later call of a model response with an id the response or the conversation already holds gets the next free <id>_n before the function-invoking client sees it, so two calls of one id no longer break the conversation (BUG-225).
IsReadOnlyAction Func<string, bool>? null Declares by tool name the actions that only read — same as [MentorAction(ReadOnly = true)], for [Description] methods or central declaration. Published to MCP clients as readOnlyHint, which some clients take as leave to skip their own confirmation; the actions RequireLookupForRecordQuestions may require. A confirmation-gated tool is never read-only
RequiresApproval Func<string, bool>? null Forces approval for a tool by name — the way to gate MCP tools and skills you don't own. Asked again on every call and on every tools/list and tools/call: keep it a cheap, pure function of the name
ActionPolicyTimeout TimeSpan 00:00:05 How long each call to an IMentorActionPolicy may take; then the call does not run (PolicyFailed), and the action is left out when MentorAgent decides what to offer. Zero = no limit
ConfirmationMessage Func<MentorConfirmationContext, string?>? null Writes the text of the confirmation banner from the tool, its description, the arguments (in parameter order, cleaned, cut at 200 characters), Language and the scope's services. It is the message of the hub's ConfirmationRequired event and of the SSE confirmationRequired frame. null, white space or an exception: the default text
ConfirmationTimeout TimeSpan 00:00:00 How long a confirmation waits for its answer, from when the banner is shown; then it counts as rejected and the action does not run. Zero = no limit; a few minutes suits a web host
AutoApprovalRules IList<Func<ToolAutoApprovalRuleContext, ValueTask<bool>>> empty Native mode only. Approves a call before any banner appears, and enables "don't ask again". Waives the prompt, never RequiredRoles
MaxAutoApprovalIterations int? null Cap on re-runs caused by auto-approval. Each one is a fresh billable model call and a per-request limit cannot bound it
IncludeWorkflowExceptionDetails bool false Put the exception message of a failing Level-2 / Level-3 workflow agent (handoff, group chat) into the reply the user reads. Development only, never in production

Session & History

Option Type Default Description
MaxSessionMessages int 50 Max messages in session history, tool calls and results included; trimmed by whole turns, oldest first. Not read when ChatReducer is set
ChatReducer IChatReducer? null Replaces the default history reducer (MentorChatReducer, which keeps tool calls with their results). new MessageCountingChatReducer(50) restores the behaviour up to rc.14. Not used with a provider you supply or with UseServiceManagedHistory
ChatHistoryProvider ChatHistoryProvider? null One provider instance shared by every connection and user — single-user hosts only, or a provider that partitions its own storage
ChatHistoryProviderFactory Func<IServiceProvider, ChatHistoryProvider>? null Builds a provider once per coordinator (per hub connection) — the isolation the default has. Prefer this on any multi-user server. Setting both stops the host when it starts
UseServiceManagedHistory bool false Set when the client keeps the conversation on the service (Responses API, Foundry, Copilot Studio) — required to avoid AF's "Only ConversationId or ChatHistoryProvider" error. Disables MaxSessionMessages, ChatReducer and EnableCompaction. A model response that gives two calls the same id is passed on as it came, with a warning: the service holds the calls under the ids it generated (BUG-225).
SessionContextPlacement MentorSessionContextPlacement Instructions Where the lines that change with every request go (page, page data, address, date and time). LastUserMessage puts them at the start of the user's last message, so the instructions stay the same and a local engine keeps its prompt cache. Path A with local history only (see The request's context in the user's message in the MentorAgent README)

Provider package versions. With Azure OpenAI's Responses API, hold OpenAI at 2.10 (Microsoft.Extensions.AI.OpenAI [10.6.0]): with 2.11 or later GetResponsesClient() throws MissingMethodException when called. Chat Completions needs no hold. The details are in the Novior.MentorAgent README, under Provider package versions. Up to 1.0.0-rc.14 the core package stopped such builds with error MENTOR001; the check is gone.

Requirements

  • .NET 10.0+
  • ASP.NET Core runtime 10.0.13 or later: the hub runs on the runtime's SignalR, and earlier 10.0 runtimes carry CVE-2026-70159
  • Novior.MentorAgent package (required dependency — installed automatically)
  • An AI provider (Azure OpenAI, OpenAI, Ollama, etc.)

Package Purpose
Novior.MentorAgent Required — AI orchestration engine
Novior.MentorAgent.Blazor Blazor WASM client
Novior.MentorAgent.Abstractions Shared foundation (transitive — no need to install)
Novior.MentorAgent.Declarative Optional — define Level-2 specialist agents in YAML instead of C#

License

Apache License 2.0 — the full text is in the LICENSE file inside this package, with the copyright notice in NOTICE. Versions up to and including 1.0.0-rc.14 were released under the MIT License, which still applies to them.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.2.0 87 10/27/2026
1.1.0 123 10/21/2026
1.0.0 115 10/13/2026

1.2.0 - 2026-10-27

### Added

- **Novior.MentorAgent.Abstractions:** `IMentorStateService.OnActionStarted` gives the tool's technical name next to
 the text shown, in a `MentorActionStarted`: `OnActionExecuting` carries only the text shown, so whoever recorded
 the actions from it could not tell for sure which tool ran. It is raised where the turn runs, not by the WebAssembly
 client, and the interface implements it as an event nobody raises, so an implementation written for 1.1.0 still
 compiles. The documentation of `OnActionExecuting` now says it carries the text shown, not the tool name.

- **Novior.MentorAgent.Abstractions, Novior.MentorAgent:** `IMentorActionObserver` receives every decision of the tool
 gate, on every surface and for every agent; `AddMentorActionObserver<T>()` registers one. A `MentorActionDecision`
 carries the outcome (`MentorActionOutcome`), a reason code that stays the same across versions
 (`MentorActionReasons`), the tool, the agent, the surface, the session, turn and call ids, the user and who
 confirmed — never the arguments. A decision never waits for an observer: each has a queue of its own and 5 seconds a
 call.

- **Novior.MentorAgent.Abstractions:** an action that asks for confirmation knows who confirmed it and when:
 `MentorActionConfirmation.Current`, read inside the action's method, carries the confirmation's id, the tool, the
 time and who confirmed — the caller of `POST /mentor/approve`, or the session's user in a circuit and in the MAUI
 app — in Blocking and in Native mode. It is `null` for an action nobody confirmed. The approval event came when
 the action was already on its way, so a ledger of the writes could not say who approved one.

- **Novior.MentorAgent:** `MentorOptions.ConfirmationTimeout` bounds how long a confirmation waits for its answer,
 counted from when the banner is shown. Once it has passed with no answer, the confirmation counts as rejected: the
 action does not run, its decision is `Rejected` with the new reason code `confirmation-timeout`, the span's
 `mentoragent.tool.confirmation` is `timed-out`, and the model is told nobody confirmed it in time, not that the user
 declined. An answer that comes later is ignored, as one after Stop is: `POST /mentor/approve` answers 404. It applies
 to a confirmation a policy asks for too, in Blocking and in Native mode, and for specialists and team members. Zero or
 a negative value, the default, is no limit, as before.

- **Novior.MentorAgent, .Abstractions, .Blazor, .Server:** two tools for article 50 of the EU AI Act. A notice that
 tells people they are talking to an AI assistant, under the widget's header for the whole conversation
 (`ShowAiDisclosure`, `AiDisclosureMessage`, on `MentorOptions` and `MentorAgentBlazorOptions`), with a default text
 in the ten languages of `Language`; and a machine-readable marker on every reply the model writes:
 `data-ai-generated="true"` on the reply in the widget, `"aiGenerated": true` on the `chunk` and `completed` frames of
 `/mentor/chat`, the hub event `ReplyStarted` before each reply, whose argument is a `MentorReplyStarted`
 (`{ "aiGenerated": true }`), and `aiGenerated` in the metadata of the reply to an A2A task. Turning the notice off
 leaves the marker. The results of the MCP server, the spoken replies and the images and files of the provider's
 hosted tools carry no marker. Which words to show, and whether the article applies, is the application's to decide.

- **Novior.MentorAgent.Abstractions:** `MentorTheme.Auto` follows the visitor's system setting
 (`prefers-color-scheme`): the light theme, `Minimal`, when it asks for light, which is also what a browser reports
 when the system states no preference, and `Default` when it asks for dark. It follows the system, not the page: on
 a page that is always light, a visitor whose system is dark sees the dark widget. No script is involved, and the
 default theme stays `Default`.

- **Novior.MentorAgent, .Abstractions, .Blazor, .Server:** a language per page. `<ChatWidget Language="…" />` gives
 the widget a language of its own for its texts, its voice and its onboarding tour, and every turn it sends carries
 it: the assistant answers in it, and what MentorAgent writes for that turn follows it. The language travels as
 `MentorUserMessage.Language`, in the hub's new `SendUserMessage`, as `language` on `/mentor/chat` and on `GET
 /mentor/tour?language=`. `IMentorOrchestrator` and `IMentorTour` get default methods, so implementations written for
 1.1.0 compile. A change of `MentorOptions.Language` while the application runs applies from the next turn. A2A and
 MCP requests stay on `MentorOptions.Language`.
- **Novior.MentorAgent:** `MentorOptions.SessionContextPlacement`. With `LastUserMessage` the context of each request
 — the page, its data, its address, the date and time — goes at the start of the user's last message instead of the
 end of the instructions, which then stay the same through the session: a model that caches the prompt by its prefix,
 a local engine above all, no longer reads the whole conversation again on every new minute or page. The stored
 conversation keeps the user's words. Path A with local history only; the default, `Instructions`, changes nothing.

### Changed

- **Novior.MentorAgent, .Abstractions, .Blazor:** the widget now shows, by default, under its header: "You are talking
 to an AI assistant: its replies are generated automatically.", in the configured `Language`, for the whole
 conversation. Set `AiDisclosureMessage` to your own words, or `ShowAiDisclosure` to `false` to show none.

- **Novior.MentorAgent.Server:** the hub sends `ReplyStarted` before each reply. A client that does not register it
 keeps working, and SignalR logs a warning for every reply ("Failed to find handler" in .NET, "No client method with
 the name" in JavaScript): register it, even with a handler that does nothing — `connection.on("ReplyStarted", () =>
 {})`, or `hub.On<MentorReplyStarted>("ReplyStarted", _ => { })`. The WebAssembly client of Novior.MentorAgent.Blazor
 registers it.

- Every package is now checked at pack time against the public API of 1.1.0, so 1.2.0 cannot break code built
 against 1.1.0 by accident. An intended break is listed in this file as Breaking.

- The family moves onto the Microsoft Agent Framework 1.26.0 line (`Microsoft.Agents.AI` and
 `Microsoft.Agents.AI.Workflows` 1.26.0) and `Microsoft.Extensions.AI` 10.11.0. **Novior.MentorAgent.Declarative:**
 `Microsoft.Agents.AI.Declarative` moves to 1.26.0-rc1, on the same line. In Agent Framework 1.26
 `AgentSessionStore.DeleteSessionAsync` is abstract: a host with an `AgentSessionStore` of its own implements it.
 1.26 also refuses two tool approval requests with one id; MentorAgent gives each call of a response an id of its
 own (BUG-225), except with `UseServiceManagedHistory`.

- With Agent Framework 1.25 or later, when control returns to an agent of the handoff workflow behind `route_to_specialist`,
 the workflow repeats the user's request to it instead of leaving it a tool result alone: whoever reads the
 workflow's messages sees one more after each return. The answers do not change.

- `ModelContextProtocol` moves to 2.3.0: the MCP client in Novior.MentorAgent, and
 `ModelContextProtocol.AspNetCore` 2.3.0 in **Novior.MentorAgent.Server**. The SDK's HTTP server is stateless by
 default from 2.0; the MCP endpoint asks for the stateful transport, so a client keeps its `Mcp-Session-Id` and the
 endpoint answers as before, a `GET` without a session with 400 included.

- An MCP server of the host that lists a tool without `inputSchema` is no longer connected: ModelContextProtocol 2.0
 refuses such a tool where 1.x gave it an empty schema, and the server's whole tool list is lost with it. The
 warning names the server, and its exception names `inputSchema`; the assistant goes on without that server.

- The A2A SDK moves to 1.0.0-preview3: `A2A` in Novior.MentorAgent and `A2A.AspNetCore` in
 **Novior.MentorAgent.Server**.

- **Novior.MentorAgent:** a call refused for its roles is logged once, at Warning, in Blocking and in Native mode
 alike. In Native mode a user who was not signed in was refused before the banner with a Debug line only, and in
 Blocking mode a missing role wrote two Warnings. A confirmation cancelled by Stop or by the end of the session is
 now logged at Debug in Native mode too, and a tool call's Debug line is written once instead of twice.

- The `mentor.tool.{toolName}` and `mentor.mcp.tool.{toolName}` spans carry the outcome
 (`mentoragent.tool.outcome`), the reason code (`mentoragent.tool.reason`), the confirmation
 (`mentoragent.tool.confirmation`) and the surface (`mentoragent.surface`), and a failed call sets the span's
 status to error. In Native mode a call refused, rejected or stopped before it runs now has its span too.

- **Novior.MentorAgent.Server:** the MCP server's line for each `tools/call` tells apart a call refused for its roles
 (`denied-role`), an action the server never publishes (`withheld`) and a name nothing defines (`unknown`): the
 three were all `unknown`. A call the client cancels is `cancelled`. The caller is still told the same for every name
 it may not use.

- **Novior.MentorAgent.Abstractions, Novior.MentorAgent, .Server:** `IMentorActionPolicy` lets the application's own
 code decide whether a user may use an action and whether a call may run — a permission per company or per record, a
 rule on the arguments — on every surface: the coordinator on both paths and in both modes, the specialists, team
 members and agent sources, the MCP servers the application consumes, the MCP server and A2A tasks.
 `AddMentorActionPolicy<T>()` registers one. `IsAllowedAsync` decides what is offered and is asked again before every
 call; `BeforeActionAsync` answers `Proceed`, `Confirm` or `Stop`, and the strictest answer of all the policies wins.
 A policy that throws, or does not answer within the new `MentorOptions.ActionPolicyTimeout` (5 seconds), stops the
 call. The gate's decisions gain the outcomes `Blocked` and `PolicyFailed` and `MentorActionDecision.Detail`.

- **Novior.MentorAgent.Abstractions, Novior.MentorAgent:** `IMentorActionCatalog.GetActions()` lists the
 application's actions as MentorAgent sees them — the coordinator's, the specialists' and the team members' — with
 their roles, whether a call asks for a confirmation now, whether they only read, whether and to whom the MCP server
 publishes them (`MentorMcpExposure`) and whether the A2A card lists them, decided at every call by the rules of the
 MCP server and of the card.

- **Novior.MentorAgent:** a change of default. With `MentorshipLevel.Proactive` the prompt no longer asks for
 suggested next steps at the end of an answer, and asks for none: told to offer only what the request's tools,
 pages, skills and specialists can do, the model still offered steps the application does not have (BUG-074, S3).
 Measured on 15 turns of the same build, the closing offers went from 15 to 2, and the offers of something the
 application does not have from 4 to 0. The new `MentorOptions.ProactiveNextSteps` brings the old rule back, word for
 word, when set to `true`. The rest of the level, the explanations, the warnings and the one sentence before
 acting, and the other levels do not change.

- **Novior.MentorAgent:** the package README opens with what MentorAgent is today: AI agents for any .NET
 application, with the chat widget for Blazor and .NET MAUI, and any other frontend through
 `Novior.MentorAgent.Server`; it links the documentation at docs.mentoragent.net. The package description and tags
 say the same. The repository's front page on GitHub is now a short README.

- SECURITY.md says when a vulnerability report is answered (within 5 working days, best effort), how a fix is
 announced (a GitHub Security Advisory and the release notes) and how long each version gets security fixes: 3
 months after the next minor version, 6 months after the next major version; 1.0.x until 21 January 2027.
 SUPPORT.md and the README say the same.

- The README says how to run MentorAgent on more than one instance, and the policy by which it follows Agent
 Framework, Microsoft.Extensions.AI, ModelContextProtocol and A2A.

### Fixed

- **Novior.MentorAgent:** with Agent Framework 1.23 or later, an agent offered two tools with the same name — two
 level-1 methods, or one name from two MCP servers — failed every turn with "Duplicate tool name". Each agent is now
 offered one tool per name: the first, which is the one a call by that name already ran. A warning names every copy
 and the one kept. A name the coordinator shares with a specialist stays with both (BUG-195, S2).

- **Novior.MentorAgent.Declarative:** with Agent Framework 1.23 or later, `MentorDeclarativeOptions.ConfigurationSection`
 gave a definition no values: the framework loads configuration only for the keys a definition names through `Env`
 and the host lists as allowed. The package now loads the section itself, one Power Fx variable per key, named by its
 full configuration path, as before (BUG-196, S2).

- **Novior.MentorAgent, .Server:** on A2A 1.0.0-preview3 a turn delegated to a peer came back empty, with no error. A
 preview3 server refuses a streamed request unless its agent declares streaming, and the SDK's client reads the
 refusal as an empty stream; the agent MentorAgent registered with the SDK declared nothing. It now declares
 streaming, as the published card did. Towards any peer, `A2AChatClient` streams only when the peer's card declares
 streaming and otherwise asks once without a stream, and an empty stream logs a warning naming the peer (BUG-197, S2).

- **Novior.MentorAgent:** a shared MCP server that connected but did not answer `ping` was opened and dropped again
 in a loop with no limit, and the turn never reached the model. A session that does not answer `ping` right after it
 was opened now counts as a failed connect: the server is left out for the failure window, like one that is down,
 and a warning says it connected but does not answer ping (BUG-198, S3).

- **Novior.MentorAgent:** the coordinator's model was offered every action, those the user had no role for included:
 asked for one, it announced it and the gate refused it. It is now offered, and counts, only the actions the user may
 run, decided at the start of every turn on both paths (BUG-200, S3), and with `RequireActionForChangeRequests` such
 an action is no longer one the first call can be required to make (BUG-201, S3). A turn that leaves an action out
 says so in its instructions, naming no action and no role; a call made anyway is refused, as before.

- **Novior.MentorAgent, .Server:** the MCP server read the `RequiresApproval` predicate once, when it built its tool
 catalogue at the first request: an action the predicate gated after that stayed in `tools/list` and ran for a caller
 nobody could ask. The predicate is now read for every `tools/list` and every `tools/call`; an action it holds back is
 not listed and, called, is refused as withheld (BUG-202, S2).

- **Novior.MentorAgent:** in `MentorHitlMode.Native` the approval markers are decided when the coordinator is built,
 so a tool the `RequiresApproval` predicate gated after that ran with no confirmation, and with an auto-approval rule
 its decision read `auto-approved` though no rule was asked. The gate now asks for the confirmation of such a call,
 with the same banner and the same `POST /mentor/approve` as in Blocking mode, and a task from another agent refuses
 it with `nobody-to-confirm` (BUG-203, S2).

- **Novior.MentorAgent:** a Stop pressed after the user confirmed an action, before the action started, did not stop
 it. Nothing read the turn's token between the answer and the run, so an action that does not read it itself ran, in
 Blocking and in Native mode, and for a specialist's action too. The gate now reads it right before the action runs:
 the action does not run, its decision is `Stopped` with `stop`, and the model is told the user stopped the request.
 In Native mode the answers are no longer sent once Stop has come, so the next turn closes the request as never run
 instead of as one that may have (BUG-204, S2).

- **Novior.MentorAgent, .Server:** a caller that cancelled an A2A task (`tasks/cancel`) was answered `Working`, and the
 task never ended `Canceled`. A caller waiting for the reply saw the turn go on, its actions included, and the task
 end `Completed`; a streamed or `returnImmediately` task stopped, but stayed `Working` for good. Cancelling now stops
 the task's turn as Stop does — nothing starts after it, and the call in progress is `Stopped` with `stop`, not
 `session-ended` — and the task ends `Canceled`, in all three cases. The A2A SDK cancels the turn before it tells the
 agent, so MentorAgent puts its own request handler in front of the SDK's; `MapMentorAgentA2A()` logs a warning when
 a version of the SDK cannot be wrapped, and a cancelled task still ends `Canceled` then (BUG-199, S2).

- **Novior.MentorAgent:** specialists, team members and the agents of an `IMentorAgentSource` were offered every tool
 they hold, those the user had no role for included, and the gate then refused the call. They are now offered, and
 told, only the tools the user may run, by the coordinator's rule, in both modes; a request that leaves a tool out
 carries the coordinator's notice (BUG-205, S3).

- **Novior.MentorAgent, .Server:** the onboarding tour was written from every action of the application, those that
 need a role included: it could suggest one its reader may not use, and `GET /mentor/tour`, which answers anyone,
 signed in or not, could describe actions the MCP server describes to nobody without the role. The tour is still one
 for every visitor, generated once; it is now written from the actions that need no role (BUG-206, S3).

- **Novior.MentorAgent:** the onboarding tour was generated on the request of the first visitor who asked for it. A
 visitor who closed the page while the model wrote it, or whose `GET /mentor/tour` was cut short, ended the
 generation, and the built-in tour, with no question to try, was kept for every visitor until the process restarted.
 The generation now depends on no visitor's request: one who stops waiting only stops waiting, and the tour the model
 writes is the one everyone gets (BUG-207, S4).

- **Novior.MentorAgent:** in `HitlMode.Native` the banner of a coordinator's action took its text — the description
 and the order of the arguments — from the first method found with the tool's name, not from the method the call
 runs, though the 1.1.0 notes said the banner followed that method in both modes (BUG-188). Since an agent is
 offered one tool per name (BUG-195), the two differ when the first method's class is not registered and the
 coordinator is offered a later one: the user was asked to confirm what the other method does. The banner now reads
 as the method the call runs in Native mode too (BUG-208, S3).

- **Novior.MentorAgent.Abstractions:** the stylesheet and script links `ChatWidget` writes kept `?v=10` from the
 first release on, while both files changed in every release. Neither file is fingerprinted, and a host serving them
 as classic static files sends no `Cache-Control`, so a returning visitor's browser could keep the previous
 release's copies after an upgrade: new elements unstyled, and what the older script lacks failing silently. The
 links now carry the version the package was built as, with its commit (`?v=1.2.0+…`). A host that writes the two
 links itself — a standalone WASM `index.html`, a surface without `ChatWidget` — sets `?v=` to the package version,
 `?v=1.2.0` for this release, and changes it on every upgrade (BUG-209, S3).

- **Novior.MentorAgent:** a Stop pressed while a request was delegated to a remote agent over A2A stopped the local
 turn and told the peer nothing: the peer finished the task, actions that ask for no confirmation included. A turn
 that leaves a delegated task before its answer now cancels it on the peer (`CancelTask`), waiting two seconds at
 most for the peer to take it; a Stop that comes before the peer's first event, which names the task, keeps the
 request open up to two seconds for it. A peer whose card declares no streaming is now asked with
 `returnImmediately`, and its task read with `GetTask` until it ends, so that it can be cancelled too (BUG-210, S2).

- **Novior.MentorAgent:** a Stop during the first message of a session, while a remote agent's card was being read,
 was logged as the agent not answering ("did not answer: The operation was canceled") and kept the agent out of
 reach for a minute, and the session's coordinator was built without it for as long as the session lasted. A Stop
 there now ends the build: nothing is remembered against the agent, and the next message builds the coordinator
 with it (BUG-211, S3).

- **Breaking — Novior.MentorAgent:** a tool result became a generative card by its shape alone: any object with a
 non-empty string `kind`. A record of the application's with such a property, a support ticket with a link a
 customer wrote in it, was drawn as a card, the link a button, and its other fields never reached the model. A
 result is now read for cards only when the method that produced it is declared to return them: `MentorCard`, or a
 sequence of them, directly or through a task. A method that returned cards as `object`, as a `JsonElement` built
 by hand or as a type of its own shows them as text: declare `MentorCard` or `IEnumerable<MentorCard>`. A `MentorCard` an
 `OnToolResult` hook returns is still a card; an MCP tool's result never is (BUG-212, S3).

- **Novior.MentorAgent.Abstractions:** under a Content-Security-Policy without `style-src 'unsafe-inline'`, such as
 `default-src 'self'`, the Dark and Minimal themes and a `PrimaryColor` were dropped, and `<MentorDashboard />` lost
 the colours of its charts and the width of its bars. The themes are now stylesheets of the package, a `PrimaryColor`
 is set with `style.setProperty` once the widget's script runs, and the dashboard uses classes and SVG: both work
 under `default-src 'self'` with any option. A host's `html:root` rule no longer wins over the five variables a
 `PrimaryColor` sets (BUG-213, S3).

- **Novior.MentorAgent.Abstractions:** IntelliSense described `MentorTheme.Default` as "Light theme with blue accents"
 and `Minimal` as having no shadows. `Default` is the baseline's dark glass, a navy panel at 55% opacity, which on a
 light page shows as dark grey with the page behind it; the light theme is `Minimal`. The documentation now says
 which is which (BUG-214, S4).

- **Novior.MentorAgent.Abstractions:** a Markdown link in a reply, `[text](https://…)`, was shown as raw text, brackets
 and parentheses included, and could not be clicked. It is now a link that opens in a new tab, with
 `rel="noopener noreferrer nofollow"`, when its address is an absolute `http` or `https` one; any other scheme,
 `javascript:` and `data:` included, and a relative address stay text. The model's HTML is still always encoded
 (BUG-215, S3).

- **Novior.MentorAgent.Abstractions:** numbered steps with a line of text between them started again from 1 after the
 line. A numbered list now starts at its first step's number, and a line or a code block indented under a step stays
 inside it (BUG-216, S3).

- **Novior.MentorAgent.Abstractions:** a message holding the text the Markdown renderer uses as a placeholder for a
 code block, `@@BMCODE7@@` for instance, threw out of the render: on Blazor Server the circuit ended, and the host's
 page stopped responding until a reload. The text may come from the user, or from the model repeating data others
 wrote. It is now shown as it is (BUG-217, S1).

- **Novior.MentorAgent:** with a `Language` other than English, the instructions MentorAgent writes for the model —
 its example sentences, the grounding rule, the instructions in tool results — were in English, and a model could
 copy one into its reply ("Understood, l'email è stata annullata"). They now follow `Language`, in all ten languages;
 in English they are unchanged (BUG-221, S3).

- **Novior.MentorAgent:** with `RefuseOutOfScope`, a short reply to the assistant's question was judged on its own:
 on the demo the assistant asked "Vuoi vedere i dettagli…?", the user answered "Sì", and the turn was refused as out
 of scope, 5 times of 5 in Italian ("Yes" passed 3 of 3). The scope question is now shown the end of the
 assistant's last reply, in the same classifier call, and the verdict is kept for the message and that reply
 together, so the same "Sì" after another question is judged again. The safety question still judges the message
 alone, and the first message of a conversation is asked as before (BUG-223, S2).

- **Novior.MentorAgent:** the notice of a turn that leaves out an action the user may not run (BUG-200) was English
 in every language, and in Italian the model offered the action and, after "Sì", used another one or made up its
 result. It now follows the language of the turn, under its English heading (English unchanged), and ends with
 three rules: no offer of such an action, a yes does not make it available, no other tool in its place. On the demo
 the card of `show_expense` is now shown, its Approve button only for a manager or an administrator (BUG-224, S2; a
 residue is listed under *Known issues*).

- **Novior.MentorAgent:** in Native mode a response with two calls of one id broke the conversation until it was
 reset ("An item with the same key has already been added"). A call whose id the response or the conversation
 already holds now gets the next free `<id>_n`, on the coordinator and the agents under it; not with
 `UseServiceManagedHistory` (a warning says so) nor on path B. Two turns in a row that fail the same way on the
 requests left open log an error that suggests a new conversation (BUG-225, S2).

- **Novior.MentorAgent:** an A2A caller told the task at once (`ReturnImmediately`) could lose who it is: the SDK
 answers it as soon as the task is submitted, its request ends while the turn runs on, and the turn read the caller
 from that request only then. An authenticated peer became anonymous, its role-gated actions were refused, its
 memory and rate limit went under another key. The caller is now read when the task starts (BUG-226, S2).

- **Novior.MentorAgent.Server:** the Server README's option tables left out `MentorOptions.ConfirmationMessage`,
 although the 1.1.0 notes said every option was in a reference table of both READMEs or among the Server README's
 options not listed on purpose (BUG-163). It is now in the table of the confirmation options, and a test checks that
 the tables of both READMEs list every option of `MentorOptions` and every property of `[MentorAction]` (BUG-227,
 S4).

- **Novior.MentorAgent.Abstractions:** with the onboarding tour open, a change of the widget's `Language` translated
 the tour's buttons and left its steps in the language it had opened in. An open tour now fetches its steps in the
 new language and stays on the step it was at, or on the last one when the new language has fewer (BUG-228, S4).

- **Novior.MentorAgent:** `RequireLookupForRecordQuestions` had no effect without semantic tool filtering, and on the
 demo, which has no embedding model, a question about pending expenses asked in Italian was answered about one time
 in three with a list the model made up. Without the filter, the first call of a record question must now call one of
 the declared reads the user may run, or hand the request over; with the filter nothing changes. The demo turns the
 option on (BUG-222, S2).

### Known issues

- **Novior.MentorAgent:** BUG-074 (S3) is closed by default: since 1.2.0 `MentorshipLevel.Proactive` asks for no
 closing offers unless `ProactiveNextSteps` is on. With it on, the assistant can still offer next steps the
 application does not have — 4 of 15 closing offers on the headless server sample, measured on 1.2.0.
- **Novior.MentorAgent:** BUG-118 (S2) is closed for hosts that opt in to `RequireActionForChangeRequests`. Without it
 the model can still narrate an action it never performed — "Stock aggiornato" with no tool called, or "Ecco la
 pagina Prodotti" with no navigation — and with it a complete request the classifier does not recognise runs as
 without the option.
- **Novior.MentorAgent:** BUG-220 (S3): with `MentorHitlMode.Blocking` and a Chat Completions client, the model can
 announce an action that asks for no confirmation — "Procedo a registrare la nota…" — and end its reply without
 calling it: once in 12 turns on the Blazor Server sample, on 1.2.0. `RequireActionForChangeRequests` does not
 prevent it, since it requires only actions that ask for a confirmation. The user can repeat the request; where it
 matters, give the action `RequiresConfirmation = true`, so that with the option a complete request calls it first,
 behind the banner; or use `MentorHitlMode.Native` with a Responses client, where an earlier measurement saw the
 action called almost every time.
- **Novior.MentorAgent:** BUG-224 (S2) leaves a residue in a turn in a language other than English: when the role
 filter leaves an action out, the assistant can still offer it, and after the user's "yes" it can, rarely, call
 another action the user may run in its place — once in 10 Italian tries on the demo, a new expense claim instead of
 an approval. The confirmation that write actions ask for by default stops that call: keep `RequireConfirmation`
 and `RequireConfirmationForWrites` on.
- **Novior.MentorAgent:** BUG-106's residue is closed for hosts that opt in to `RequireLookupForRecordQuestions`;
 without it, a catalogue question can still be answered with an offer to look the value up.
- **Novior.MentorAgent, .Server:** a 1.1.0 host that moves the A2A SDK to 1.0.0-preview3 on its own — `A2A`, or
 `A2A.AspNetCore`, which brings it — cannot answer A2A tasks. NuGet allows it without a warning, since 1.1.0 asks
 for preview2 or later, but preview3 changed the signatures of four `TaskUpdater` methods the 1.1.0 handler calls:
 the handler fails with `MissingMethodException` when a task arrives. Keep `A2A` and `A2A.AspNetCore` at
 1.0.0-preview2 with 1.1.0, or move to 1.2.0.

The full history is in CHANGELOG.md, inside this package.