MentorAgent.Server 1.0.0-preview.2

This is a prerelease version of MentorAgent.Server.
There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package MentorAgent.Server --version 1.0.0-preview.2
                    
NuGet\Install-Package MentorAgent.Server -Version 1.0.0-preview.2
                    
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.2" />
                    
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.2" />
                    
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.2
                    
#r "nuget: MentorAgent.Server, 1.0.0-preview.2"
                    
#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.2
                    
#: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.2&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=MentorAgent.Server&version=1.0.0-preview.2&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
/mentor/approve HTTP POST All clients — HITL confirmation/rejection (see HITL note)
/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);
    }
}

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

// ── Cancel current request ────────────────────────────────────────────────────
await connection.invoke('CancelRequest');

// ── 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 Show citation chips below the AI message
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 Send user message to the AI
CancelRequest Cancel the current in-flight AI request
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' });
    };

    const cancel  = () => connRef.current?.invoke('CancelRequest');
    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  = () => this.connection.invoke('CancelRequest');
    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' });
    };

    const cancel = () => connection.invoke('CancelRequest');
    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();

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

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

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.


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 loaded per session

The AI automatically calls remember() to store user preferences, name, role, and recurring needs — silently and without prompting the user. Facts persist across sessions.

Provide a custom store:

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

RAG — Retrieval-Augmented Generation

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

options.UseRag         = true;
options.RagResultCount = 3;
options.RagMinScore    = 0.7f;
options.ShowRagSources = true;  // show citations to the user

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

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 — role-based actions and per-user rate limiting

For RequiredRoles and per-user rate limiting to work, configure ASP.NET Core authentication before AddMentorAgent(). MentorAgent reads the user identity from IHttpContextAccessor / AuthenticationStateProvider and resolves the ClaimTypes.NameIdentifier claim.

// 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;  // now truly per-user, not global
});

Without authentication, all users share the key "anonymous" for rate limiting, and RequiredRoles checks will fail gracefully with a permission-denied message.


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

Memory

Option Type Default Description
UseMemoryContext bool false Enable automatic user memory
MemoryContextCount int 10 Max memories injected per session

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

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

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.5 0 8/12/2026
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

- Refreshed the package icon (new MentorAgent "MA" logo).
- README: fixed the HITL examples for Angular, Vue and MAUI/console — they now use POST /mentor/approve instead of the deadlocking RespondToApproval hub call.
- README: added the McpServerStatusChanged event to all client examples (React, Angular, Vue, MAUI).