MentorAgent.Server 1.0.0-preview.4

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

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

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
/mcp MCP server Claude Desktop, VS Code Copilot, Cursor, any MCP client
/.well-known/agent-card.json + /a2a A2A agent Other AI agents, orchestrators

Getting started

Installation

dotnet add package MentorAgent.Server --prerelease

MentorAgent is 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 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
app.MapMentorAgentA2A();   // optional

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)

Streaming-only. Best for simple chat UIs that only need text responses.

const response = await fetch('/mentor/chat?message=' + encodeURIComponent(text));
const reader   = response.body.getReader();
const decoder  = new TextDecoder();

while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    for (const line of decoder.decode(value).split('\n')) {
        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)

Supports all events: streaming, HITL confirmations, navigation, UI actions, RAG citations, team collaboration, action feedback.

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

await connection.start();

// ── Send messages ─────────────────────────────────────────────────────────────
await connection.invoke('SendMessage', 'Mostrami gli ordini pending');

// ── Send a message with images (multimodal) ──────────────────────────────────
// The trailing argument is optional — 1-argument clients keep working unchanged.
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 AI response fully received Finalize the message, enable input
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[] RAG documents retrieved for this response — 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
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
Client → Server methods
Method Parameters Description
UpdatePageContext snapshot: PageContextSnapshot Send current page state before each message. Call before SendMessage
SendMessage text: string, attachments?: MentorAttachment[] Send a user message to the AI. attachments is optional and carries images for a multimodal turn; omit it for text-only. 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)
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"
}
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);
    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 }]);
        await connRef.current?.invoke('SendMessage', text);
    };

    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 }]);
        await this.connection.invoke('SendMessage', text);
    }

    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 });
        await connection.invoke('SendMessage', text);
    };

    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.
    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();
await connection.InvokeAsync("SendMessage", "Ciao!");
Console.ReadLine();
await connection.StopAsync();

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. Anything rejected is dropped with a warning log; the turn still runs with whatever passed.

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

Wire format

{
  "mimeType":   "image/png",       // must be in AllowedImageTypes
  "dataBase64": "iVBORw0KGgo…",    // inline bytes, NO "data:" prefix   ─┐ exactly
  "url":        null,              // …or a public https URL            ─┘ one of the two
  "fileName":   "screenshot.png"   // optional, display only
}
Transport How to send
SignalR connection.invoke('SendMessage', text, attachments) — optional trailing argument
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 set EnableImageInput = true.


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

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

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

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

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

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

Provider support is not universal:

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

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

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 StrongChatClient on a client that supports hosted tools too — otherwise turns work until one is escalated and then fail. MentorAgent warns at startup when both are configured.

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

HostedToolsDeclared(int flags)     // MentorHostedTools bit flags

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

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

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

Live activity over the wire

A hosted tool runs remotely and can take several seconds with nothing streamed. With options.ShowHostedToolActivity (default true) the server reports it over the transport your client already handles:

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

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

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

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

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

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

Keeping them from firing when they are not needed

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

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

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

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

[MentorAgent] 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 EnableToolFiltering and an EmbeddingGenerator. Without them nothing is filtered and hosted tools keep firing on every turn — with a startup warning naming the options being ignored, because a control that is switched on but never runs is worse than one that is off. Losing a capability because a model was not configured is worse than costing more than expected, and there is deliberately no keyword fallback.


AI providers

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

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

// Ollama (local)
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) => ...;

    [MentorAction("cancel_order", 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 (needs at least one [MentorPage]) The assistant asks the client to change page. Over SignalR/SSE this surfaces as the NavigateTo event — your client decides how to route (React Router, Angular Router, MAUI Shell)
route_to_specialist At least one [MentorAgent] or RemoteAgents entry Hands the turn to a Level-2 specialist or a remote A2A agent through the handoff workflow
{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 NavigateTo 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.pdf       ← attached resource

Class-based:

[MentorSkill("shipping", Description = "Shipping and tracking operations")]
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 },
    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');

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/approvenot a hub method.

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

// 1. Receive the confirmation request
connection.on('ConfirmationRequired', (actionId, toolName, message) => {
    showConfirmDialog(message, {
        onConfirm: () => fetch(`/mentor/approve?actionId=${actionId}&approved=true`,  { method: 'POST' }),
        onCancel:  () => fetch(`/mentor/approve?actionId=${actionId}&approved=false`, { method: 'POST' })
    });
});

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 (ApprovalRequiredAIFunctionToolApprovalRequestContentToolApprovalResponseContent), 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 AF ToolApprovalRequestContent raised inside a workflow never surfaces to the orchestrator. Same event, same POST /mentor/approve, asked exactly once.


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

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

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

[MentorPage(Url = "/products", Name = "Products",
    Description = "Product catalog with stock and pricing",
    HasUIActions = true,     // AI waits for SignalReady() before invoking UI actions
    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.


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 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 AddMentorAgentServer():

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;  // show citations to the user

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 ReceiveChunk 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 and call POST /mentor/cancel?connectionId=<id>. Whatever was already streamed stays in the transcript.

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


Token & cost optimization

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 — in: 1979, out: 62, call total: 2041 | 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)

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: Tool filtering: 7/47 tools sent (7 core + 0 matched, 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 with store).

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)

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


Observability (OpenTelemetry)

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

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

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

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

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


Token & cost dashboard

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

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

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

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

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

Persistence (optional)

By default the snapshot is in-RAM and resets on restart. Register an IMentorMetricsStore before AddMentorAgentServer() for durability or an external source — the endpoint then returns await store.QueryAsync() ?? metrics.GetSnapshot():

// 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 5 minutes) controls how often the registered store is flushed; MetricsRetention (default 7 days) bounds how much of the hourly time series the snapshot carries.


Model routing

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

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

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

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


Structured outputs

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

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

// Inject IMentorStructured (registered by AddMentorAgentServer)
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.


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;
options.McpServerPath    = "/mcp";
options.ShowMcpStatus    = true;
app.MapMentorAgentMcp();

Every [MentorAction] method becomes an MCP tool. 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";
options.A2AServerUrl     = "https://myapp.example.com";
app.MapMentorAgentA2A();

Security

// AI-based safety check (detects prompt injection and jailbreaks)
options.EnableSafetyCheck = true;   // adds ~200-500ms per message

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

// Role-based actions
[MentorAction("delete_record", RequiredRoles = ["Admin"])]
public Task DeleteAsync(string id) => ...;

// Confirmation dialogs for destructive actions
[MentorAction("cancel_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:

const connection = new signalR.HubConnectionBuilder()
    .withUrl('/mentor-hub', { accessTokenFactory: () => myAccessToken })
    .withAutomaticReconnect()
    .build();

Without authentication configured, every session shares the key "anonymous" (shared memory, global rate limit), and RequiredRoles actions fail closed (blocked). The bridge is registered with TryAddScoped, so it never overrides an AuthenticationStateProvider a Blazor host already provides.


Attribute reference

[MentorAction] parameters

Parameter Description
Description Natural language description used as the AI tool description
Category Groups actions in proactive suggestion chips
RequiresConfirmation Shows a confirmation banner before executing. Use for destructive or irreversible operations
RequiredRoles ASP.NET Core identity roles required to invoke the action. Empty = accessible to all
ProactiveHint Hint injected into the AI prompt to guide proactive behaviour
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. Auto-generated from Name + Description when omitted

[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, AI waits for PageContext.SignalReady() before executing UI actions. Default: false
ReadyTimeout Timeout in ms for SignalReady(). 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. Provide a persistent store via ChatHistoryProvider:

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

IMentorSessionManager is automatically registered by AddMentorAgent(). Inject it in any service or controller:

// Inject in a service or controller
public class SessionController(IMentorSessionManager sessionManager) : ControllerBase
{
    [HttpGet("session/save")]
    public async Task<IActionResult> Save()
    {
        // Serialize the current session (e.g. save to Redis or DB)
        JsonElement? snapshot = await sessionManager.SerializeCurrentSessionAsync(agent);
        return Ok(snapshot);
    }

    [HttpPost("session/restore")]
    public async Task<IActionResult> Restore([FromBody] JsonElement snapshot)
    {
        // Restore on reconnect (e.g. after server restart)
        await sessionManager.RestoreSessionAsync(agent, snapshot);
        return Ok();
    }
}

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 Emit follow-up suggestions with the reply. Headless clients receive them alongside the answer and choose whether to render them as chips — set false to stop generating them at all
EnableActionFeedback bool true Emit ActionExecuting / ActionCompleted while a tool runs. This is what drives "Searching the web…" in your client; false means the turn streams the answer with no progress events
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

Not listed here on purpose. MentorOptions also carries Theme, Position, PrimaryColor, BotName, WelcomeMessage, InputPlaceholder and AvatarUrl. They style the Blazor widget and have no effect on a headless backend — your React/Angular/Vue/MAUI client renders its own UI. Set them only if you also serve <ChatWidget /> from a Blazor project; for MentorAgent.Blazor clients they live in MentorAgentBlazorOptions on the client side instead.

Multimodal image input

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

Hosted tools

Option Type Default Description
HostedTools MentorHostedTools None Provider-hosted tools: WebSearch, CodeInterpreter, FileSearch, ImageGeneration, HostedMcp (flags) — see Hosted tools
FileSearchVectorStoreIds IReadOnlyList<string>? null Vector stores searched by FileSearch. Required when it is on — without ids the tool is skipped (fail-closed)
FileSearchMaxResults int? null Upper bound on file-search matches
HostedImageModel string? null Model used by ImageGeneration. On Azure OpenAI this is the deployment name (e.g. gpt-image-1-mini) — arbitrary, so it cannot be guessed; without it the call can fail mid-turn with a deployment-not-found error
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? null0.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)
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

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

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

Middleware, observability & dashboard

Option Type Default Description
InputGuardrail Func<string,CancellationToken,Task<bool>>? null Custom input guardrail (true = safe); replaces the built-in check
OutputGuardrail Func<string,CancellationToken,Task<bool>>? null Moderate the completed reply (true = safe); buffers the reply then reveals it (no live streaming that turn)
OnToolResult Func<string,object?,object?>? null Transform/redact a tool result before it returns to the model
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 5 min How often a registered IMentorMetricsStore is flushed. Without a store the snapshot is in-RAM and resets on restart
MetricsRetention TimeSpan 7 d 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)

Also available as services (resolve from DI): IMentorStructured — typed GenerateAsync<T>, see Structured outputs — and MentorEvaluator, the token/quality regression harness, see Evaluation & regression testing.

Memory

Option Type Default Description
UseMemoryContext bool false Enable automatic user memory
MemoryContextCount int 10 Max memories injected per session
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 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)

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.50.75
RagSystemPromptTemplate string "Use the following documents...\n{documents}" Prompt template
ShowRagSources bool false Show citation chips in widget

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

MCP

Option Type Default Description
McpServers MentorMcpServer[]? null External MCP servers as L1 tools
McpServerEnabled bool false Expose as MCP server. Also call app.MapMentorAgentMcp()
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()
A2AServerUrl string? null Full public URL of this agent's A2A endpoint (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
MaxMessageLength int 4000 Max message length (0 = unlimited)
RateLimitPerUser int 0 Max messages per minute per user (0 = disabled)
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
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 Persistent conversation history provider
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

Requirements

  • .NET 10.0+
  • MentorAgent package (required dependency — installed automatically)
  • An AI provider (Azure OpenAI, OpenAI, Ollama, etc.)
Package Purpose
MentorAgent Required — AI orchestration engine
MentorAgent.Blazor Blazor WASM client
MentorAgent.Abstractions Shared foundation (transitive — no need to install)
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-preview.4 52 8/4/2026
1.0.0-preview.3 58 7/24/2026
1.0.0-preview.2 66 6/22/2026
1.0.0-preview 73 6/22/2026

1.0.0-preview.4

=== Provider-hosted tools for headless backends =============================
- NEW — options.HostedTools declares provider-side web search, a sandboxed code interpreter, file search, image generation and remote MCP servers (options.HostedMcpServers). The tools run on the model provider and their results appear in the streamed answer, so existing React/Angular/Vue/MAUI clients keep working with no update. Requires a client that supports them (the Responses API or Foundry; Chat Completions supports web search only) and, with the Responses API, options.UseServiceManagedHistory. The README carries the provider-support matrix and the setup steps.
- NEW — options.ShowHostedToolActivity (default true) reports provider-side work on events your client already handles: ActionExecuting / ActionCompleted for "Searching the web…" / "Running code…", and RagSourcesReady for the pages cited by web search (same payload shape as RAG). One new event, GeneratedImages(string[]), carries images from the hosted image-generation tool as data: URIs or absolute URLs, ready for <img src>. Purely additive: a client that does not subscribe keeps working exactly as before.
- NEW — HostedToolsDeclared(int): one server→client event, sent once on connect with the active MentorHostedTools flags, so a client can render its capability badge from what the server actually enabled instead of mirroring the list in its own configuration (a copy that drifts the moment the server changes). Also purely additive.
- NEW — options.HostedImageModel and options.HostedImageSize (WIDTHxHEIGHT — the cost knob, since size is what the image is billed on). ⚠️ On Azure OpenAI the model name alone is not enough: the image deployment must travel as the x-ms-oai-image-generation-deployment REQUEST HEADER. Add it as a PipelinePolicy on your AzureOpenAIClient — MentorAgent.Server receives an already-built IChatClient and cannot add it. See Infrastructure/ImageDeploymentHeaderPolicy.cs in the samples.

=== Cost control on the hosted tools ========================================
- NEW — options.FilterHostedTools (default true) puts hosted tools through the semantic tool filter, so a web search or a file search is declared only on turns whose message is relevant to it instead of on every message. They were the only group the filter never touched, while the cheap application tools were trimmed on every call. options.HostedToolFilterMinScore sets their own threshold (default 0.15, deliberately lower than ToolFilterMinScore because the two populations score on different scales); options.MaxHostedToolCallsPerSession caps them per session. ⚠️ 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.
- NEW — options.HostedToolDomainCheck (+ HostedToolDomainScope / HostedToolDomainClassifier): hosted tools run only when the message concerns the application at all. A different question from per-tool relevance — "draw me a dog" is a genuine image request and nonsense for a shop backend — and the one that stops paying for it. A small model decides, once per turn and only when a hosted tool would otherwise run, so ordinary turns cost nothing. Fails open. See the MentorAgent (core) notes for why this is a classifier and not a similarity threshold.
- All three controls run inside the semantic tool filter, which is installed only with options.EnableToolFiltering and an EmbeddingGenerator. Without those, nothing is filtered and hosted tools keep firing on every turn — fail-open, with a startup warning that names the options being ignored.
- Fix — when one of these controls withholds a hosted tool, the server now tells the model so in that same request, and the model refuses plainly instead of inventing a result. Without it an image request answered with a fabricated URL presented as a generated image. See the MentorAgent (core) notes.

=== Hosted-tool diagnostics =================================================
- Fix — hosted file search produced no live activity for remote clients. It is the one hosted tool Microsoft.Extensions.AI 10.6.0 has no content type for (its call/result arrive as the plain base types, its matched documents as annotations on the answer text), so nothing was emitted. Clients now receive ActionExecuting/ActionCompleted for it and RagSourcesReady with the matched documents — same payload shape as web citations, except a document cited from a vector store has no sourceUrl (there is nothing to open): render its title and snippet.
- Fix — the ConfirmationRequired event for a provider-side MCP call carried the raw approval id ("mcpr_0805…") as the tool name. It now carries the tool name and the remote server ("Microsoft Docs Search · microsoft_learn") plus the arguments. No wire-format change: same event, same POST /mentor/approve reply.
- Fix (diagnostics) — provider-side failures during a turn (a rejected hosted tool, a missing deployment) were logged only as "Stream content: ErrorContent", with no cause; the message, code and details are now logged at Error level. These never reach the middleware and can end a turn with zero tokens and no exception, so that line was the only trace.
- Fix (image generation was unusable) — enabling ImageGeneration failed every turn with ArgumentNullException while the request was being assembled. Microsoft.Extensions.AI 10.6.0 converts ImageGenerationOptions.MediaType into the OpenAI output-file-format with no null check and the tool carries none by default; "image/png" is now always sent.

=== Human-in-the-loop approval ==============================================
- NEW — options.HitlMode = Blocking (default, unchanged) or Native (the Agent Framework's ApprovalRequiredAIFunction / ToolApprovalRequestContent flow). Deliberately NO wire-format change: both modes emit the same ConfirmationRequired(actionId, toolName, message) event and accept the same POST /mentor/approve reply, so every existing SignalR/SSE client keeps working untouched when you switch mode.
- Fix (SECURITY) — tools invoked inside a Level-2 specialist or a Level-3 team bypassed the confirmation banner AND the RequiredRoles check, because those agents run their own function-calling loop inside the workflow. The gate is now attached to the function itself, so it applies at every level. See the MentorAgent (core) notes.
- Fix — MentorMcpServer.RequiresConfirmation is now enforced; previously a server marked that way had its tools executed without asking. Also new: options.RequiresApproval to gate any tool by name (MCP, skills, remote agents).
- Docs — the RespondToApproval hub method is now explicitly marked backwards-compatibility-only in the protocol reference. POST /mentor/approve remains the correct path, because SignalR dispatches one hub invocation at a time per connection.

=== Multimodal image input over both transports =============================
- NEW — SignalR: SendMessage(text, attachments?) — the trailing argument is optional, so existing 1-argument clients keep working unchanged. SSE: a new POST /mentor/chat accepting { "message": "...", "attachments": [ { "mimeType", "dataBase64" | "url", "fileName" } ] }, because a base64 image does not fit in a query string (GET remains for text-only). Enable server-side with EnableImageInput and a vision-capable ChatClient; every attachment is re-validated against the allow-list (AllowedImageTypes / MaxImageBytes / MaxImagesPerMessage). An image-only turn (empty message) is valid. The README carries upload/paste/drag-drop/URL examples for React, Angular, Vue and .NET MAUI.
- Fix (image attachments over SignalR) — sending any real photo from a SignalR client failed silently: the widget echoed the user bubble and then nothing happened — no reply, no error, no busy indicator. Attachments ride inside the SendMessage invocation as base64 and SignalR's default MaximumReceiveMessageSize is 32 KB, so the server aborted the connection ("The maximum message size of 32768B was exceeded"). AddMentorAgentServer() now raises HubOptions<MentorHub>.MaximumReceiveMessageSize to MaxImageBytes * MaxImagesPerMessage * 4/3 + 512 KB when EnableImageInput is on — scoped to the MentorAgent hub so the host's own hubs are untouched, and only ever raising an existing value. The limit is sized from your image options, so keep MaxImageBytes / MaxImagesPerMessage tight.

=== Transport and turn-lifecycle fixes ======================================
- Fix (Stop button) — cancelling an in-flight turn never worked for SignalR clients. SignalR dispatches at most one hub invocation at a time per connection (MaximumParallelInvocationsPerClient = 1), so the CancelRequest hub method queued behind the still-streaming SendMessage and only ran after the turn it was meant to abort had finished. Added POST /mentor/cancel?connectionId=… which reaches the connection's live orchestrator out of band — the same reasoning that already applies to POST /mentor/approve. The CancelRequest hub method is kept for backwards compatibility but only works while idle. README client examples (React, Angular, Vue) updated.
- Fix (SEVERE) — with options.UseServiceManagedHistory every local tool call failed: the server answered "An error occurred while processing the response" on any turn that used a tool. Two middlewares copied ChatOptions property-by-property and dropped ConversationId, so the follow-up request lost previous_response_id and the provider rejected the tool result with "HTTP 400 — No tool call found for function call output with call_id …". Fixed by using ChatOptions.Clone(). Two related fixes: tool filtering and model routing now decide once per turn instead of once per request, since a mid-turn change of tool list or deployment is rejected the same way.