MentorAgent.Server
1.0.0-rc.12
dotnet add package MentorAgent.Server --version 1.0.0-rc.12
NuGet\Install-Package MentorAgent.Server -Version 1.0.0-rc.12
<PackageReference Include="MentorAgent.Server" Version="1.0.0-rc.12" />
<PackageVersion Include="MentorAgent.Server" Version="1.0.0-rc.12" />
<PackageReference Include="MentorAgent.Server" />
paket add MentorAgent.Server --version 1.0.0-rc.12
#r "nuget: MentorAgent.Server, 1.0.0-rc.12"
#:package MentorAgent.Server@1.0.0-rc.12
#addin nuget:?package=MentorAgent.Server&version=1.0.0-rc.12&prerelease
#tool nuget:?package=MentorAgent.Server&version=1.0.0-rc.12&prerelease
MentorAgent.Server
Preview Release — MentorAgent is currently in public preview. APIs may change before the stable release.
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.
MentorAgent.Server adds a SignalR hub, SSE streaming endpoint, and bridges the MCP/A2A servers already built into MentorAgent — 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 MentorAgent.Blazor for Blazor WebAssembly and Blazor Auto deployments.
Table of Contents
- Package Family
- What MentorAgent.Server exposes
- Getting started
- CORS — required for cross-origin clients
- Connecting clients
- Multimodal image input
- Hosted tools (web search, code interpreter, file search, images, remote MCP)
- AI providers
- The three-level agent model
- Built-in AI tools
- Agent Skills
- Page context and UI actions
- HITL — Confirming actions
- Page navigation
- Contextual memory
- RAG — Retrieval-Augmented Generation
- Streaming responses
- Token & cost optimization
- Middleware & extensibility
- Observability (OpenTelemetry)
- Token & cost dashboard
- Model routing
- Structured outputs
- Rich responses (tables & lists)
- Generative UI — cards (Level 2)
- Declarative agents (YAML)
- Onboarding tour
- Evaluation & regression testing
- MCP — Model Context Protocol
- A2A — Agent-to-Agent
- Security
- Attribute reference
- Persistent conversation history
- Session serialize and restore
- All configuration options
- Requirements
- Related Packages
- License
Package Family
| Package | Install when |
|---|---|
| MentorAgent | Blazor Server app |
| MentorAgent.Server ← you are here | Web API / headless backend, or Blazor Auto server-side project |
| MentorAgent.Blazor | Blazor WASM / Blazor Auto client project |
What 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 404 when that connection has no live scope — a normal race if Stop is pressed just as the turn ends, so do not treat it as an error |
/mentor/tour |
HTTP GET | All clients — onboarding tour steps as JSON |
/mentor/session |
HTTP GET / POST | All SignalR clients: saves and restores the connection's conversation (?connectionId=...). GET returns the snapshot (204 when there is nothing to save yet). POST with the snapshot as JSON body restores it (400 if it is not a MentorAgent snapshot). Owner-checked like approve/cancel. 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 |
A reconnect is a new connection and therefore a new conversation. Save while the connection is alive and restore on the new one:
// save (e.g. on every StreamingCompleted, or before navigating away)
const res = await fetch(`/mentor/session?connectionId=${connection.connectionId}`);
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', 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 MentorAgent.Server --prerelease
MentorAgentis included automatically as a transitive dependency — you do not need to install it separately.
Minimal setup (Web API)
// Program.cs — Web API or Minimal API
using Azure.AI.OpenAI; // AzureOpenAIClient
using Microsoft.Extensions.AI; // AsIChatClient()
using MentorAgent.Abstractions.Models; // MentorLanguage
using MentorAgent.Extensions;
using 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
app.MapMentorAgentA2A(); // optional: requires options.A2AServerEnabled = true, otherwise it throws at startup
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"); // ← must come before MapMentorAgentServer()
app.MapMentorAgentServer();
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/chatrequest is its own conversation. The endpoint runs the turn in the request's own DI scope, so everyGET/POSTstarts 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 aPageContextSnapshot, 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.
| Frame | Payload | What it is for |
|---|---|---|
chunk |
text |
One streamed fragment of the answer |
completed |
— | 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('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 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.
await fetch(`/mentor/cancel?connectionId=${connection.connectionId}`, { method: 'POST' });
// ── 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.
await fetch(`/mentor/approve?actionId=${actionId}&approved=true`, { method: 'POST' }); // approve
await fetch(`/mentor/approve?actionId=${actionId}&approved=false`, { method: 'POST' }); // reject
Complete SignalR Protocol Reference
Server → Client events
| Event | Parameters | When fired | What to do |
|---|---|---|---|
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, etc.) | Show error message to user |
ActionExecuting |
action: string |
A tool/agent is executing | 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 → 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 |
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 |
— | Clear conversation history and start fresh |
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) |
⚠️
SendMessagetakes two arguments — alwaysconnection.invoke('SendMessage', text, null); // ✅ text-only connection.invoke('SendMessage', text, attachments); // ✅ with images connection.invoke('SendMessage', text); // ❌ rejected by the serverThe 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,
EnableDetailedErrorsis 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 (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)
}
Complete chat component — React
Two files: the hook that manages the SignalR connection, and the component that renders the UI.
// useMentorHub.ts
import { useEffect, useRef, useState } from 'react';
import * as signalR from '@microsoft/signalr';
export function useMentorHub(hubUrl: string) {
const connRef = useRef<signalR.HubConnection | null>(null);
// Origin for the HTTP side-channel (approve/cancel): 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
useEffect(() => {
const conn = new signalR.HubConnectionBuilder()
.withUrl(hubUrl)
.withAutomaticReconnect()
.build();
conn.on('StreamingChunk', c => {
streamingRef.current += c;
setStreaming(p => p + c);
});
conn.on('StreamingCompleted', () => {
setMessages(m => [...m, { role: 'assistant', text: streamingRef.current }]);
streamingRef.current = '';
setStreaming('');
});
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.)
await fetch(`${baseUrl}/mentor/approve?actionId=${id}&approved=${approved}`, { method: 'POST' });
};
// Cancel over HTTP, not the hub — a hub call cannot run while SendMessage is streaming.
const cancel = () => fetch(`${baseUrl}/mentor/cancel?connectionId=${connRef.current?.connectionId}`, { method: 'POST' });
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('');
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.
npm install @microsoft/signalr
// mentor-hub.service.ts
import { Injectable, OnDestroy, signal } from '@angular/core';
import * as signalR from '@microsoft/signalr';
import { Router } from '@angular/router';
@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 connection: signalR.HubConnection;
constructor(private router: Router) {
this.connection = new signalR.HubConnectionBuilder()
.withUrl('/mentor-hub')
.withAutomaticReconnect()
.build();
this.registerHandlers();
this.connection.start();
}
private registerHandlers(): void {
this.connection.on('StreamingChunk', (c: string) => this.streaming.update(p => p + c));
this.connection.on('StreamingCompleted', () => {
this.messages.update(m => [...m, { role: 'assistant', text: this.streaming() }]);
this.streaming.set('');
});
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.
await fetch(`/mentor/approve?actionId=${id}&approved=${approved}`, { method: 'POST' });
}
// Cancel over HTTP, not the hub — a hub call cannot run while SendMessage is streaming.
cancel = () => fetch(`/mentor/cancel?connectionId=${this.connection.connectionId}`, { method: 'POST' });
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
npm install @microsoft/signalr
// useMentorHub.ts
import { ref, onUnmounted } from 'vue';
import * as signalR from '@microsoft/signalr';
import { useRouter } from 'vue-router';
export function useMentorHub(hubUrl: string) {
const router = useRouter();
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 connection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl)
.withAutomaticReconnect()
.build();
connection.on('StreamingChunk', (c: string) => streaming.value += c);
connection.on('StreamingCompleted', () => {
messages.value.push({ role: 'assistant', text: streaming.value });
streaming.value = '';
});
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).
await fetch(`/mentor/approve?actionId=${id}&approved=${approved}`, { method: 'POST' });
};
// Cancel over HTTP, not the hub — a hub call cannot run while SendMessage is streaming.
const cancel = () => fetch(`/mentor/cancel?connectionId=${connection.connectionId}`, { method: 'POST' });
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('');
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<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 |
(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, 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 eachGET/POST /mentor/chatrequest 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.Sharedputs every anonymous caller on the single key"anonymous". One user's remembered facts are then injected into another user's turns, andMemoryAutoCapture(on by default) keeps writing into that shared bucket. Measured beforePerSessionbecame the default: one anonymousPOST /mentor/chatstored a private code, and a second, entirely separate anonymous request read it back. UseSharedonly for a single-user host.MentorAgent logs a warning at startup when
/mentor/chatis left without authorization and eitherUseMemoryContextorMemoryAutoCaptureistrue. That includes the defaults (MemoryAutoCapture = true,UseMemoryContext = false), even though nothing is captured untilUseMemoryContextis 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. UnderPerSession, an anonymous caller gets a fresh allowance with every new hub connection and every SSE request, so the limit does not bound an anonymous client that reconnects or calls/mentor/chatrepeatedly. UnderShared, every anonymous caller spends one allowance. To bound anonymous spend, require authentication on the transports (configureTransports).
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.
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
SendMessagehub invocation as base64, and SignalR's defaultMaximumReceiveMessageSizeis 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. WhenEnableImageInputis on,AddMentorAgentServer()therefore raisesHubOptions<MentorHub>.MaximumReceiveMessageSizetoMaxImageBytes × 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. KeepMaxImageBytes/MaxImagesPerMessagetight — 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
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[]
<textarea
onPaste={async e => {
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)));
}}
/>
await connection.invoke('SendMessage', text, attachments);
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 (
MentorAgent/MentorAgent.Blazor) already implements upload, paste, drag & drop and URL out of the box — just setEnableImageInput = 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 + EnableCompaction
With model routing: the strong model gets the same tool list, so build
StrongChatClienton 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 aPOST /mentor/chatcall 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] 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 (classifier said 'OUT').
[MentorAgent] Hosted tools withheld: the request is outside this application's scope.
[MentorAgent] Tool filtering: 7/51 tools sent (7 core + 0/40 matched + 0/4 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. 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
EnableToolFilteringand anEmbeddingGenerator. 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)
options.ChatClient = new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.2");
// Azure AI Foundry — requires AIAgent
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];
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. 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 |
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
RespondToApprovalvia hub? ASP.NET Core SignalR processes hub messages sequentially per connection. WhileSendMessageis awaiting the confirmation TCS, the dispatcher cannot process any other hub message from the same connection. CallingRespondToApprovalvia 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 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 model round-trip, streaming stays alive. Native uses the AF standard instead (ApprovalRequiredAIFunction → ToolApprovalRequestContent → ToolApprovalResponseContent), which costs one extra round-trip per approved call but makes the flow 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.
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 AFToolApprovalRequestContentraised inside a workflow never surfaces to the orchestrator. Same event, samePOST /mentor/approve, asked exactly once.
Page navigation
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 inside a Blazor Server circuit — 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.
HasUIActionson a headless server.navigate_towaits forPageContext.SignalReady()only when the turn runs inside an interactive Blazor Server circuit. A turn that arrives over the SignalR hub (WebAssembly, React, MAUI…), SSE or A2A has no circuit, so nothing on the server could ever signal "ready":navigate_todoes not wait (a Debug log records it) and returns at once. The new page's UI actions reach the model with the client's nextUpdatePageContextsnapshot — that is, on the user's next message. SettingHasUIActionscosts 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) 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 on this path; forget_all stays. On Path B (a pre-built Agent, no ChatClient) it falls back to the remember tool. 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
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.
Implement IMentorRagSource:
public class MyVectorDbSource : IMentorRagSource
{
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
CancelRequesthub call would sit in the queue behind the verySendMessageit is meant to abort and only run once that turn had already finished. The same reasoning applies to/mentor/approve.
Token & cost optimization
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. When nothing is relevant (e.g. "hello"), only core tools are sent. Log (Debug): Tool filtering: 7/47 tools sent (7 core + 0/40 matched + 0/0 hosted, minScore=0.35).
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
In-memory history only (Path A /
ChatClient) — not service-managed history (Foundry, Responses API withstore).
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. 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").
⚠️
ObservabilityIncludeSensitiveDataadds 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
DashboardRolerequires ASP.NET Core authentication/authorization to be configured (app.UseAuthentication()/app.UseAuthorization()); otherwise the endpoint has authorization metadata with no middleware to enforce it. UseDashboardRole = ""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; requiresEmbeddingGenerator.Classifier— a tiny LLM call labels the turnSIMPLE/COMPLEX.Cascade— serves on cheap, judges completeness, re-runs on strong only if it fell short.Custom— your predicate viaUseStrongModelAsync(async, whole conversation) or legacyUseStrongModel.
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
StrongChatClienton 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.
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}") ],
};
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 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. 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
urlis 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 tourlwithout 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.
localStorageis 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 —MentorAgent.Blazor, or your own Web Speech API code. A headless server never speaks; it receives a transcript as an ordinary message.EnableVoiceInput/EnableVoiceOutputexist onMentorOptionsonly 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
options.McpServerEnabled = true;
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. A startup warning lists the withheld actions. Until you protect it, the endpoint is open, with an AllowAnyOrigin() CORS policy.
builder.Services.AddHttpContextAccessor();
builder.Services.AddMentorAgent(options =>
{
options.McpServerEnabled = true;
// Publishes a role-gated action only to an authenticated caller that holds the role.
var http = builder.Services.BuildServiceProvider().GetRequiredService<IHttpContextAccessor>();
options.McpCallerPrincipal = () => http.HttpContext?.User;
});
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
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
JSONRPCbinding atA2AServerUrl, which is why that must be the endpoint's full URL. It also lists your ungated[MentorAction]s as skills (actions withRequiredRoles, a confirmation orRequiresApprovalare left out), 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 any tool, the reply is thrown away and the same request runs once more with a tool call required. If that second attempt is also tool-less and the classifier model decides the reply states application data, the peer receives
NOT GROUNDED: …instead of the figure. 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 theroute_to_specialistdescription). 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 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.
- 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.
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() bridges the ASP.NET Core authenticated principal to the MentorAgent core automatically. It reads the current user from the SignalR HubCallerContext.User (hub clients) or HttpContext.User (SSE clients) and resolves the ClaimTypes.NameIdentifier claim. This makes per-user memory, per-user rate limiting and RequiredRoles work for any client — React, Vue, Angular, WASM, MAUI — not only Blazor.
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
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 (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 WebAssembly template does).
A WebSocket handshake cannot send an
Authorizationheader, so SignalR appends the token as?access_token=…. With JWT bearer on the server, forward it inOnMessageReceivedor 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.Sharedputs them all on the single key"anonymous"(shared memory, one global rate limit). Either way,RequiredRolesactions fail closed (blocked). The bridge is registered withTryAddScoped, so it never overrides anAuthenticationStateProvidera Blazor host already provides.
Attribute reference
[MentorAction] parameters
| Parameter | Description |
|---|---|
Description |
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 |
RequiredRoles |
ASP.NET Core identity roles: the caller needs any one of them (not all), must be authenticated, and an AuthenticationStateProvider must be registered (AddMentorAgentServer() bridges one). 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 |
[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 inside an interactive Blazor Server circuit; hub, SSE and A2A turns never wait (see Page navigation). Default: false |
ReadyTimeout |
— | Timeout in ms for SignalReady() (Blazor Server circuits 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.
⚠️
MentorOptionsis a singleton. An object assigned toChatHistoryProviderserves 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 useChatHistoryProviderFactory: it is invoked once per coordinator (once per hub connection) and receives that scope'sIServiceProvider, 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 throws at startup.
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);
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 MentorAgent snapshot (live conversation untouched) · 404 unknown connection |
Both are owner-checked like /mentor/approve (401 / 403 for a caller that is not the connection's principal): send the same credentials the hub connection used. The client-side save/restore example is under What MentorAgent.Server exposes.
A
404straight after connecting can mean the request overtook the hub'sOnConnectedAsync: 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
public class SessionController(IMentorOrchestrator mentor) : ControllerBase
{
[HttpGet("session/save")]
public async Task<IActionResult> Save()
{
// Save the current conversation (e.g. to Redis or a database).
// null means "nothing to save yet" — do not store it over a good snapshot.
JsonElement? snapshot = await mentor.SerializeSessionAsync();
return snapshot is null ? NoContent() : Ok(snapshot);
}
[HttpPost("session/restore")]
public async Task<IActionResult> Restore([FromBody] JsonElement snapshot)
{
// 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.
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 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 answers404. Restore on the new connection id afterwithAutomaticReconnect()brings the connection back, or the user silently starts over.
The lower-level
IMentorSessionManageris registered too, but every method on it takes the coordinatorAIAgent, 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) clears history and starts a fresh AgentSession automatically.
All configuration options
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) |
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 |
MentorshipLevel |
MentorshipLevel |
Standard |
AI proactivity: Minimal / Standard / Proactive |
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/MAUI client |
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 |
Not listed here on purpose.
MentorOptionsalso carriesTheme,Position,PrimaryColor,BotName,WelcomeMessage,InputPlaceholder,AvatarUrland the playback optionsVoiceStreaming,VoiceBargeIn,VoiceHandsFree,VoiceRate. They style or drive the Blazor widget and have no effect on a headless backend — your React/Angular/Vue/MAUI client renders its own UI and, for the voice ones, does its own Web Speech API work. Set them only if you also serve<ChatWidget />from a Blazor project; forMentorAgent.Blazorclients they live inMentorAgentBlazorOptionson 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 |
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 |
EnableCompaction |
bool |
false |
Compact long conversation history before each call (in-memory history / Path A only) |
CompactionTokenThreshold |
int |
4000 |
Token budget that triggers compaction |
CompactionMaxTurns |
int |
8 |
Recent turns kept intact |
EmbeddingGeneratoralso 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 |
OnException |
Func<Exception,string?>? |
null |
Map an exception to a user-facing message (null → default) |
ConfigureChatClientPipeline |
Func<ChatClientBuilder,ChatClientBuilder>? |
null |
Insert custom middleware into the Path A pipeline |
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, fact extraction). A small deployment here cuts the wait before every reply; defaults to ChatClient |
Also available as services (resolve from DI):
IMentorStructured— typedGenerateAsync<T>, see Structured outputs —MentorEvaluator, the token/quality regression harness, see Evaluation & regression testing — andIMentorTour, 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. On Path A (a ChatClient is set) it is the only writer — the redundant remember tool + prompt are dropped (saves tokens); on Path B 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 |
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 +
RagMinScoreinject 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() |
McpServerPath |
string |
"/mcp" |
Path app.MapMentorAgentMcp() maps when called without a path; a path passed to the method wins |
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() |
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 |
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 an off-topic message instead of answering it; free when the safety check is on (same classifier call) |
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 |
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 |
RateLimitWindowSecs |
int |
60 |
Rate limiting window in seconds |
RequireConfirmation |
bool |
true |
Global on/off for confirmation dialogs |
HitlMode |
MentorHitlMode |
Blocking |
Blocking (MentorAgent's flow) or Native (AF ApprovalRequiredAIFunction). Same client protocol either way — see HITL |
RequiresApproval |
Func<string, bool>? |
null |
Forces approval for a tool by name — the way to gate MCP tools and skills you don't own |
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 |
Include stack traces in responses. Never enable in production |
Session & History
| Option | Type | Default | Description |
|---|---|---|---|
MaxSessionMessages |
int |
50 |
Max messages in session history |
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 throws at startup |
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 and EnableCompaction |
error MENTOR001— if your build stops with this, something has pulledOpenAIpast 2.10.0, which crashesGetResponsesClient()at startup. The guard ships transitively from the core package; the full explanation and the override are in the MentorAgent README, under Requirements.
Requirements
- .NET 10.0+
MentorAgentpackage (required dependency — installed automatically)- An AI provider (Azure OpenAI, OpenAI, Ollama, etc.)
Related Packages
| Package | Purpose |
|---|---|
| MentorAgent | Required — AI orchestration engine |
| MentorAgent.Blazor | Blazor WASM client |
| MentorAgent.Abstractions | Shared foundation (transitive — no need to install) |
| MentorAgent.Declarative | Optional — define Level-2 specialist agents in YAML instead of C# |
License
MIT — the full text ships in the repository's LICENSE file.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- MentorAgent (>= 1.0.0-rc.12)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-rc.12 | 47 | 9/23/2026 |
| 1.0.0-rc.11 | 50 | 9/23/2026 |
| 1.0.0-rc.10 | 55 | 9/19/2026 |
| 1.0.0-rc.9 | 57 | 9/19/2026 |
| 1.0.0-rc.8 | 58 | 9/18/2026 |
| 1.0.0-rc.7 | 58 | 9/16/2026 |
| 1.0.0-rc.6 | 66 | 9/14/2026 |
| 1.0.0-rc.5 | 64 | 9/13/2026 |
| 1.0.0-rc.4 | 64 | 9/9/2026 |
| 1.0.0-rc.3 | 72 | 9/4/2026 |
| 1.0.0-rc.2 | 76 | 8/24/2026 |
| 1.0.0-rc.1 | 73 | 8/19/2026 |
| 1.0.0-preview.5 | 66 | 8/12/2026 |
| 1.0.0-preview.4 | 76 | 8/4/2026 |
| 1.0.0-preview.3 | 74 | 7/24/2026 |
| 1.0.0-preview.2 | 77 | 6/22/2026 |
| 1.0.0-preview | 84 | 6/22/2026 |
1.0.0-rc.12
Found by re-driving the published rc.11 on the six NuGet sample applications, with a Blazor Server host driven as the A2A peer for the first time on published packages and every remote answer held against the peer's REST data, then by a pre-publication pass of this package on the same six applications. One API addition, opt-in: MentorOptions.ToolFilterMinTools (default 0 - tool selection is unchanged from rc.11).
=== FIXED
- BUG-100 (S2): a confirmation-gated action (RequiresConfirmation or the RequiresApproval predicate) called on an A2A task held the task until the calling agent gave up: both the blocking and the native approval path waited for an answer nobody could give. It is now refused at once, in both modes, and the model is told it has to be done from the application itself. The agent card already left these actions out.
- BUG-098 (S2): an action with NavigateTo ran, then the auto-navigation read NavigationManager.Uri, which throws in a scope with no circuit, and the tool was reported failed - so the model ran it again and the caller was told it had not happened. Seen over A2A on a Blazor Server host, reachable since rc.11 made that work (BUG-083), and possible on any turn served without a circuit on a host that registers a Blazor NavigationManager. A turn from another agent no longer auto-navigates, and a page URL that cannot be read no longer turns a completed action into a failure. An exception from your own OnToolResult hook still reports an action that already ran as failed: keep that hook from throwing.
- BUG-097 (S3): on a Blazor Server host with memory or RateLimitPerUser on, every A2A task logged BUG-072's warning ("The AuthenticationStateProvider threw ... check the provider can be read from a scoped service"), and a role-gated tool the model reached for was logged as an Error with a stack trace and answered "Error during authorization check". The provider is still asked, so a headless host keeps an authenticated peer's identity as in rc.11. When it cannot be read on a task from another agent, the task is anonymous with a Debug line, and a gated tool is refused as for any anonymous caller - the usual "Tool ... blocked" warning, no Error, no stack trace.
=== MITIGATED, NOT FIXED - read this if you use EnableToolFiltering and have actions that change data
- BUG-099 (S1, present since tool filtering existed; the published rc.10 did the same): on hosts with EnableToolFiltering on (off by default), a question about stock set the stock to 0. The filter sends only the actions whose cosine score clears ToolFilterMinScore; for "Quante unita' di iPad Air ci sono in magazzino?" that was one action, the stock UPDATE (0.351), while the actions that read stock ranked just below (0.29-0.32). Handed one write action for a read question, the model called it with a quantity of 0 - over SSE 3 times of 3, over A2A 4 of 4. NEW: ToolFilterMinTools - once one action clears the threshold, send at least this many best-ranked ones, whatever their score. It is a trade-off, measured both ways, so it is off by default: with 3 the stock question read the stock and wrote nothing, but "Quanto ha speso Anna Ferrari?", whose lone match above the line was the right search, got a by-email lookup the floor had added and answered "no such customer" 0 times of 4, against 4 of 4 with the threshold alone. What protects you is [MentorAction(RequiresConfirmation = true)] on every action that changes data: a person is asked, and over A2A the action is refused (BUG-100). The sample applications now gate their stock update.
=== VERIFIED ON THE PUBLISHED rc.11 (no change)
- BUG-083: a Blazor Server host serving A2A tasks, questions put straight to its /a2a - 6 of 6 true (rc.10: every task empty).
- BUG-082: from the React sample, 8 remote-addressed questions, 8 tasks on the peer, none answered by a local specialist.
- BUG-084 (SafetyCheckTimeout with SafetyCheckFailure = Block) on a Blazor Server and a headless host; BUG-086 (a signed-in WebAssembly user's confirmation and Stop reach the hub's origin with the token); BUG-094 (a webSearch entry in a YAML definition removed with a warning).
=== PERFORMANCE
- rc.11 against this package on the six NuGet sample applications' ApiServer, same host and day, nothing else running, six questions in three rounds: median time to first character 8.9 s against 7.2 s, whole reply 12.6 s against 11.2 s, each faster in 9 of 18 paired turns, the same number of model calls, input tokens equal within 0.1 %. rc.10 against rc.11, measured the same way before: 11.8 s against 10.9 s, input tokens +1-2 % on delegated turns (the note rc.11 added to each delegated answer, saying whose answer it is) and under 1 % elsewhere.
=== STILL OPEN
- BUG-099 (S1): mitigated as above; a structural answer (actions declared read-only) is for a later version.
- BUG-074 (S3): MentorshipLevel.Proactive offers actions the application does not have. Measured, recorded, a product-voice decision.
1,733 tests green, build 0 warnings / 0 errors.
1.0.0-rc.11
Found by re-driving F2 (A2A client -> live peer) on the published rc.10 with every answer held against the peer's REST data AND its task log, and by running the Blazor Server sample on a local model (Ollama). BUG-081 is closed on the published packages. API additions: SafetyCheckTimeout and SafetyCheckFailure; auditing the five READMEs against the code then found eleven code defects behind the text, all fixed below.
=== FIXED
- BUG-082 (S2): a LOCAL specialist's answer reached the user as the REMOTE agent's. "Chiedi a ShopFlowRemote: quanto ha speso Anna Ferrari?" came back in 8.8 s with no task on the peer, reading "... EUR 5.800,00 sull'istanza ShopFlowRemote": the coordinator had called route_to_specialist with the addressee tidied out of the request and no specialist, the router gave a question about a customer to the local CustomerAgent, and the tool returned that agent's words with nothing to say whose they were. The figure matched only because the samples share their seed data. On a host that has remote agents every delegated answer now opens with its source: a local specialist answered -> a note that no remote agent was contacted and an instruction to call once more with specialist set if the user meant one (the second call carries the name, so the retry is bounded); the remote agent answered -> "that system's data, not this application's"; the remote agent was named and never took part -> "NOT X'S ANSWER", plus a warning for the operator. "Remote" means every configured peer, including one whose card could not be fetched when the session was built. The router's list marks remote entries, and its rules say that a request addressed to a remote agent is about that system's data whatever its subject. Hosts without remote agents, and turns that arrived over A2A, read byte for byte what they read before.
- BUG-083 (S2, present in rc.10): a Blazor Server host could not answer another agent - every A2A task it received completed with an empty message. A turn served over A2A has no circuit; AppContextProvider read NavigationManager.Uri unguarded, and it threw "'RemoteNavigationManager' has not been initialized". The caller saw an empty reply and told its user the remote agent was unavailable. The read is guarded, and the A2A handler now FAILS a task that produced no words instead of completing it empty. Headless hosts (MentorAgent.Server) were not affected.
- BUG-084 (S2): the safety checks had a fixed 15-second limit and failed open, so on a slow model (a local one on a CPU) the input check was skipped on every message, with a stack trace; Stop could not interrupt the input check; and a custom InputGuardrail that honoured its cancellation token threw out of SendMessageAsync, leaving the widget busy for good. NEW: SafetyCheckTimeout (default 15 s; zero or negative = no limit, and Stop still cancels) and SafetyCheckFailure (Allow - the default and the previous behaviour - or Block: refuse the message with a localized "can't check it right now, try again", or withhold the reply). Both apply to the input and the output check, built-in or custom. A timeout logs one line that says what to change; an error keeps a single stack trace per turn (BUG-071). The output check now runs on ClassifierChatClient, like the input check. Nothing changes for a host that sets neither option.
=== FIXED - found by auditing the five READMEs against the code (285 findings confirmed by a second reader: 264 were documentation, the rest code)
- WebAssembly client: answering a confirmation and Stop now go to the hub's origin with the hub's bearer token (AccessTokenProvider). They used the host's HttpClient with a relative URL and no token, so a signed-in user's confirmations and Stop were answered 401 by the owner check.
- ConfigureChatClientPipeline is built with the host's services: the documented b => b.UseLogging() made every coordinator build fail.
- ChatWidget CardTemplate: a MentorCardView inside a template reaches the widget's action dispatcher; its buttons did nothing.
- UI actions registered by a hub client (WebAssembly, React, MAUI) keep their parameter hint, and the WebAssembly page context generates the same hints as Blazor Server.
- navigate_to no longer waits for SignalReady where no page can send it (hub, SSE and A2A turns): each navigation to a page with HasUIActions cost the full ReadyTimeout and a warning.
- McpServerPath and A2AServerPath are the default paths of MapMentorAgentMcp() / MapMentorAgentA2A(); nothing read them.
- [MentorAction(ProactiveHint)] reaches the model, appended to the tool description as "Guidance: ..." (not used by semantic tool filtering); it was stored and never read.
- SkillsRefreshInterval caches the composed skill sources once per process; each session built its own cache, so the option did nothing.
- MentorAgent.Declarative: webSearch, codeInterpreter, fileSearch and mcp entries in a YAML definition are removed with a warning; only kind: function is kept. They created provider-hosted tools outside the gate, against the package's promise that a definition file cannot add a capability.
- ChatInput composed without ChatWidget no longer throws on the first message (an unguarded JS call ended the Blazor Server circuit). The output safety check is metered under ClassifierChatClient's model id.
- Documentation: 264 corrections across the five READMEs, among them attribute examples that did not compile, SignalR enums arriving as numbers, the anonymous-identity and rate-limit text (per-session since rc.5), how to protect and what to expect from /mcp and /a2a. XML docs and the server's startup warning match the PerSession default.
=== VERIFIED ON THE PUBLISHED rc.10 (no change)
- BUG-081: F2 on all five package columns - 38 remote-addressed turns, 36 delegated, 36 true figures, none invented, none bounced; "quanti clienti Premium" put straight to the published ApiServer's /a2a endpoint: 10 of 10 (rc.9: 3, 4), and "ordini Pending" 5 of 5.
- BUG-080: one task per turn on the peer, none bounced back, with ApiServer and React up together.
=== STILL OPEN, UNCHANGED
- BUG-074 (S3): MentorshipLevel.Proactive offers actions the application does not have. Measured, recorded, a product-voice decision.
1,721 tests green, build 0 warnings / 0 errors.
1.0.0-rc.10
Found by re-driving the release matrix's open cells on the published rc.9. BUG-080 holds: in the topology that looped (two hosts, each the other's remote agent) a delegated request is one task on the peer and none bounced back, and A2A context is per scope on every caller. With remote answers finally arriving, they could be compared with the data - and several were not true. No API change.
=== FIXED
- BUG-081 (S2): a turn SERVED over A2A stated figures no tool had returned. "Quanti clienti Premium ha?" asked through a caller came back as 18; put straight to the peer's /a2a endpoint, as 3, 4, 11, 16, 6, 6, 8 - there are 2. Tools were offered every time and none was called, while the same server on the same question over SSE called search_customers and said 2. The only difference was the line rc.8 added to an A2A turn's context - "carry the request out with your tools and reply with the result itself" - obeyed in the wrong order: a result at once. (BUG-079's "87 customers" was very likely this.) The notice is now a procedure: FIRST call the tool; every number, name, date or status comes from a tool result of THIS turn; if no tool has it, reply only that the application cannot provide it. A rule in a prompt lowers a rate and does not remove a behaviour - measured live, the notice alone gave 3 grounded answers in 5 - so there is a structural backstop, each step of it added because the one before was measured and was not enough. When an A2A-served turn ends without the coordinator reaching for ANY tool, the handler discards the reply and puts the same request once more, with the turn's context saying why and with a tool call REQUIRED on that attempt's first model call. If the second attempt too is tool-less, the classifier model is asked whether the reply states a value of the application's live records (a model, not a pattern: "9 clienti Premium" and "reso entro 30 giorni" both contain a number); on anything but a clear no the caller receives "NOT GROUNDED: ... do not present a figure" instead of the value. Two attempts, never three; the extra turn is paid only by tool-less A2A requests; a tool-less reply that states no data is returned as it is. Measured on the package-built ApiServer, the hardest host: 15 of 15 correct, none invented, none withheld (rc.9: 18, 3, 4 for a true 2).
- BUG-081, second half: the caller's own name for the peer travels inside the request ("quanti prodotti ha ShopFlowRemote?") and nothing told the peer that name means ITSELF - it answered "I have no access to ShopFlowRemote" 3 times in 6, without calling a tool. The notice now says so, and a MentorAgent caller sends its name for the peer as A2A message metadata (mentoragent.addressedAs), which the peer reads into the notice. The value arrives from another machine and goes into a prompt: only one token of letters, digits and - _ . (64 characters at most) is accepted; anything else is dropped whole.
=== VERIFIED ON THE PUBLISHED rc.9 (no change)
- BUG-080: API and React up together, remote turns driven from every column - one "Task received" per turn on the peer, each followed by "This turn arrived over A2A: remote agent(s) ... are not offered to it", zero tasks bounced. A2A context per scope closed on the React client and the WebAssembly client (one context across a connection's turns, a different one for a second connection).
- BUG-073, residual: the cold first turn of a fresh Chat Completions process delegated this time (route_to_specialist -> OrderAgent, real data).
=== STILL OPEN, UNCHANGED
- BUG-074 (S3): MentorshipLevel.Proactive offers actions the application does not have. Measured, recorded, a product-voice decision.
1,667 tests green, build 0 warnings / 0 errors.
1.0.0-rc.9
Found by re-driving the release matrix's open cells on the published rc.8 - the first published build on which an A2A round trip completes (BUG-078 had kept every one from finishing). T6/T7 closed on every host: route_to_specialist reaches OrderAgent and the declarative ShippingAgent, with real data, and the router's call is metered. The first remote delegation ever driven between the PUBLISHED samples found what was behind it. No API change.
=== FIXED
- BUG-080 (S2): a request delegated over A2A was delegated ONWARD by the peer, and two hosts that peer each other never stopped. Live: "Delega a ShopFlowRemote: quanti prodotti a catalogo?" on one sample produced five tasks on its peer and four on the peer's peer (each ~5,500 input tokens), 141 seconds, no answer - until a server was stopped by hand. Two causes. (1) rc.8 made route_to_specialist prefix the workflow's input with "[Specialist requested: NAME]" for the LOCAL router (BUG-076), and the A2A client sent the last user message verbatim: the marker crossed the wire and the peer's coordinator read it as its own order - every sample calls its remote agent "ShopFlowRemote". The marker now has one writer and one remover (SpecialistMarker), and the A2A client strips it on both the streaming and the non-streaming path. (2) Structural: a turn that ARRIVED over A2A was offered the host's remote agents like any other. It no longer is - they are not built for that scope and are absent from the coordinator's prompt, the router's prompt and the description of route_to_specialist; one Debug line names what was withheld. The host's own specialists, declarative agents, teams and tools still serve the request. Consequence, documented in the README: chaining A -> B -> C through a MentorAgent host is not supported in 1.0 (before rc.8 no chain could complete a single hop). Verified live on the fixed source in the mirror topology that looped (two hosts, each the other's "ShopFlowRemote"): one task per turn on the peer, zero bounced back, "8 prodotti a catalogo" in 32 s.
=== VERIFIED ON THE PUBLISHED rc.8 (no change)
- BUG-076/077: three hosts' logs show route_to_specialist returned: main_coordinator -> OrderAgent[FunctionCall] -> OrderAgent[FunctionResult] -> OrderAgent[Text]; the router's call costs ~400 input tokens and is metered.
- BUG-078: both peers' cards advertise JSONRPC at an absolute URL; tasks are received and logged with their context ids. A2A context per scope holds: one context across a circuit's turns, a different one for a second circuit (Blazor Server and MAUI callers).
- BUG-075, second pass: "Ricorda che il mio codice privato e' ..." and asking for it back are both SAFE / IN scope; no UNSAFE verdict anywhere in the process log.
- BUG-079: with the peer unreachable or looping, the user read "il sistema remoto non ha fornito il numero" - no invented figure.
- BUG-073, residual rate: on the Chat Completions host the cold first turn of a process narrated a handoff without calling the tool, once in five. The fix lowers a rate; it does not remove a behaviour.
=== SAMPLES (not part of the packages)
- MentorAgentServer's remote agent can be renamed and re-pointed from the command line (--A2ARemote:Name / --A2ARemote:Url), so the source pair can mirror the published topology (two hosts, each the other's remote, same name). The source pair that verified rc.8 could not show BUG-080 because it did not.
=== STILL OPEN, UNCHANGED
- BUG-074 (S3): MentorshipLevel.Proactive offers actions the application does not have. Measured, recorded, a product-voice decision.
1,654 tests green, build 0 warnings / 0 errors.
1.0.0-rc.8 (condensed: BUG-075 to BUG-079)
The first delegated turns ever driven live, on the published rc.7. One additive API change: route_to_specialist takes an optional `specialist` argument.
=== FIXED - BUG-078 (S2): the A2A server had never completed a round trip - the card advertised the wrong protocol binding, the handler never submitted the task and disposed its scope the wrong way, and MentorAgent's own A2A client read only Message events. BUG-076 (S2): the handoff router was built from the coordinator's whole prompt (about 5,000 input tokens paid again on every delegated turn) and narrated instead of routing; it now has a router's prompt, the tool returns what the specialists said, and "NOT DELEGATED" / "DELEGATION FAILED" say what happened. BUG-077 (S3): every model call under the coordinator (router, specialists, team members, source-built agents) was unmetered; they now run on the host's ChatClient inside the metering wrapper, and an empty model id falls back to the client's own deployment. BUG-079 (S3): generated specialist and team-member prompts carry a grounding rule (state only what a tool returned); a custom [MentorAgent(Instructions = ...)] is used exactly as written and must carry its own. BUG-075, second pass: on hosts with memory on, the safety classifier is told that what users say about themselves is theirs, not a system secret (verified on the two reported messages); the model itself may still decline to store something its user calls private.
=== CHANGED - A turn that arrives over A2A is told that no human is present.
1.0.0-rc.7
Found by the release matrix: the same ~70 checks driven on all eight sample applications against the published rc.6 packages, signed in and anonymous, on Blazor Server (cookie), WebAssembly and React (JWT), Blazor Auto, .NET MAUI (over CDP) and the two source-referenced apps. 584 cells, every option of MentorOptions and MentorAgentBlazorOptions with a verdict. No API change.
=== FIXED
- BUG-073 (S2): the coordinator was told it COULD delegate to its specialists and never told how - the tool name route_to_specialist appeared nowhere in the prompt and the tool's own description named no agent. Told a specialist existed (BUG-069), the model narrated the handoff ("inoltro subito la domanda allo ShippingAgent, attendo la sua risposta") and ended the turn without calling anything, three times, once under an explicit order. The capabilities block now says how to delegate and forbids announcing an unperformed handoff; the tool's description names every specialist it reaches, attribute-declared, source-supplied and remote.
- BUG-075 (S3): "Ricorda che il mio codice privato e' ZULU-2200" was classified UNSAFE on a host with memory on. Remembering a fact the user asks to keep is a configured capability and is now named to the classifier, the way MCP servers and image input already were (BUG-067).
- BUG-072 (S3): a signed-in user whose AuthenticationStateProvider threw was silently keyed as anonymous - and since rc.5, as a different anonymous in every circuit, so the same user would have had a different memory in every tab with nothing in the log. The fallback stays (fail closed); it is now said once per session, without a stack trace per visitor. The live check on a real cookie principal shows the catch does not fire.
- BUG-068 addendum: one collaborating builder was still announcing per circuit at Information ("Remote agent 'X' connected from ..."). Routed through the same quiet logger; the general test now includes a remote agent.
=== SAMPLES (not part of the packages, recorded for whoever reads them)
- The three samples with authentication expose GET /account/dev-login?as=admin|manager|user in Development only (404 in Production, verified), so the authenticated rows of the matrix can be driven by a script; the WebAssembly and React clients accept ?as= on their login route for the same reason.
- The Blazor Server sample binds RefuseOutOfScope, WarmUpAtStartup, CompactionMaxTurns and RateLimitPerUser from configuration, so command-line overrides actually reach them.
- The samples point their A2A peer at a live server (the headless sample) instead of a port nobody listened on.
=== MEASURED, NOT CHANGED
- BUG-074 (S3, open): MentorshipLevel.Proactive still offers actions the application does not have - 3 in 5 turns ("esportare l'elenco", "cercare con il nome"). The rule is in the prompt; a structural fix (offers must name a listed tool or page) is a product-voice decision, recorded rather than taken.
- A compaction pass writes no log line; it is pinned in-process (CompactionTests). A Debug line when a pass runs would make it observable live.
1,623 tests green, build 0 warnings / 0 errors.
1.0.0-rc.6
Four defects found by running the published rc.5 packages against the sample applications - the pass rc.5's own notes implied but had not yet been done. No API change: every fix restores behaviour rc.5 already claimed.
=== FIXED
- BUG-068 (S3): the configuration summary was NOT written once per process, as rc.5's notes said it was. A second browser circuit still reprinted seven Information lines - the skills catalogue, the external agents, the hosted image model, the Azure image-header note, the hosted MCP server, the hosted tool list and the declarative handoff. The sentinel was claimed halfway through the build, after everything above it had already announced itself, and three collaborating builders never consulted it at all. It is now claimed first, and a later build demotes Information to Debug instead of discarding it, so an operator who turns Debug on to investigate one circuit can still see what it was built with. Degradation warnings stay per session, unchanged.
- BUG-069 (S2): an agent supplied by an IMentorAgentSource - a declarative YAML specialist, or a host's own - joined the handoff graph and was never named in the coordinator's instructions. route_to_specialist names no agent either, so nothing the model could see said the specialist existed: asked about it, the assistant answered that there is no such agent, while the log recorded it being added to the workflow. Source-supplied agents are now listed with their descriptions beside the attribute-declared ones.
- BUG-070 (S4): every skill on the published A2A card carried a flattened name ("Getallordersforanalysis"). The friendly-name helper splits on underscores and was being handed the PascalCase method name. That card is the one artefact whose entire audience is another machine's directory.
- BUG-071 (S4): a turn whose input safety check met the first failure printed two stack traces instead of one - the turn's fault log was reset after that check, so its cause was recorded and immediately discarded. The reset now happens before every early return.
1,616 tests green, build 0 warnings / 0 errors.
1.0.0-rc.5 (condensed; the full account is in the repository's BUGS.md, BUG-062 to BUG-067)
Latency and cost per visitor: time to the first character fell from 2.6-3.0 s to 1.6-2.0 s on a live host - one embedding per text instead of one per consumer, one classifier call for safety and scope, the composer handed back before the post-turn fact extraction, shared MCP sessions and a process-wide agent-card cache.
=== NEW - ClassifierChatClient (a small, fast deployment for MentorAgent's own one-word decisions); AnonymousIdentity (PerSession by default - BREAKING for single-user hosts that want one shared memory: set Shared); RefuseOutOfScope; WarmUpAtStartup; MentorMcpServer.Shared; a configure callback on MapMentorAgentMcp / MapMentorAgentA2A to protect them; IMentorQueryEmbedding.
=== FIXED - BUG-064 (S1): two anonymous visitors shared one memory. BUG-062, 063, 065, 066, 067 (S3/S4): text glued across a tool call, the Native-HITL banner shown before the role check, a spurious RAG scale warning, an unmetered scope classifier, legitimate requests refused by the safety classifier.
=== CHANGED - RAG chips show only cited documents; Proactive mentorship asks for one sentence before acting; the A2A card publishes skills, modes and streaming; the configuration summary is logged once per process.
1.0.0-rc.4
No change in this package. Version aligned with MentorAgent 1.0.0-rc.4, which fixes one S3 in the provider error classifier - see that package's notes. The five packages ship as a set and are meant to be upgraded together.
1.0.0-rc.3 (condensed; the full account is BUG-051 and its neighbours in the repository's BUGS.md)
=== FIXED (S1) - the documented React, Angular, Vue and MAUI clients could not send a message. MentorHub.SendMessage takes (text, attachments) and SignalR binds arguments by COUNT, so invoke('SendMessage', text) was rejected ("Invocation provides 1 argument(s) but target expects 2", shown to the client as a generic server error). All six README call sites now pass both arguments; a client written from the old snippets must pass null as the second.
=== SECURITY - MapMentorAgentServer(configureTransports: e => e.RequireAuthorization()) protects the hub and both /mentor/chat endpoints, which had no supported way to be protected; a startup warning fires when the SSE endpoint is anonymous and memory is on (anonymous callers share one memory bucket and one rate-limit key). /mentor/approve, /mentor/cancel and /mentor/session check that the caller OWNS the connection: anonymous against an authenticated connection is 401, a different signed-in user 403.
=== NEW - GET/POST /mentor/session saves and restores a conversation over HTTP, for clients that cannot reach SerializeSessionAsync/RestoreSessionAsync (204 when there is nothing to save, 404 for an unknown connection, 400 for a snapshot that is not MentorAgent's).
=== UPGRADE - the OpenAI 2.10.0 hold is enforced: the build fails with MENTOR001 if OpenAI 2.11.0 or later resolves (override deliberately with MentorAgentSkipOpenAIVersionCheck=true). MentorAgent.Blazor rc.3 fixes WebAssembly hub authentication (S1) - upgrade the two together.