Any front end: React, Angular, Vue
Use MentorAgent from React, Angular, Vue, a mobile app or any HTTP client: SignalR or Server-Sent Events, confirmation over HTTP, and how to stop an answer.
On this page
This page shows how to use MentorAgent from a front end that is not Blazor. The backend is an ASP.NET Core app with Novior.MentorAgent.Server: it runs the agent, your actions and their rules, and holds the model key.
The front end can be React, Angular, Vue or other JavaScript, a native mobile app, a desktop app or a script. It talks to the backend over SignalR or Server-Sent Events (SSE). Other agents and tools, in any language, can reach the same backend over A2A and MCP.
When to choose it
Section titled “When to choose it”Choose it when the front end is not Blazor, or when another client, such as a mobile or console app, needs a backend without a user interface. You build the chat UI yourself, or start from the components on this page. For a Blazor app, with the ready-made widget, see Blazor Server, Blazor WebAssembly and Auto or .NET MAUI.
The two transports differ in what they carry:
| SignalR hub | Server-Sent Events | |
|---|---|---|
| Endpoint | /mentor-hub | GET or POST /mentor/chat |
| Client library | A SignalR client, such as @microsoft/signalr | None: fetch, curl or any HTTP client |
| Conversation | One per connection, across messages | Every request is a new conversation |
| Page context and UI actions | Yes | No |
| What arrives | Every event: text, busy state, actions, confirmations, navigation, citations, cards, team activity | Text, completion, errors, cards, navigation and confirmation requests |
Use the hub for a chat. Use SSE for a single question with a streamed answer.
Install the packages
Section titled “Install the packages”In the ASP.NET Core project, Novior.MentorAgent.Server brings Novior.MentorAgent with it:
.NET CLI
dotnet add package Novior.MentorAgent.ServerPackageReference
<ItemGroup>
<PackageReference Include="Novior.MentorAgent.Server" Version="1.0.0" />
</ItemGroup>Central Package Management
<ItemGroup>
<PackageVersion Include="Novior.MentorAgent.Server" Version="1.0.0" />
</ItemGroup><ItemGroup>
<PackageReference Include="Novior.MentorAgent.Server" />
</ItemGroup>File-based app
#:package Novior.MentorAgent.Server@1.0.0Neither package includes a model provider package. The server code on this page uses OpenAI, so add its package to the same project:
dotnet add package Microsoft.Extensions.AI.OpenAI --version 10.6.0For another provider, see Model providers.
In the JavaScript project, for the hub:
npm install @microsoft/signalrServer-Sent Events need no library. Clients in other languages are in Other clients.
Minimal configuration
Section titled “Minimal configuration”Keep the model key out of the source code:
dotnet user-secrets init dotnet user-secrets set OpenAI:Key <your-key>Add your actions: a service whose methods have
[MentorAction]. Register the service in dependency injection, as in Describe what the agent can do and Register the service and the model. A[MentorAction]that changes data asks for confirmation by default, so your client receives aConfirmationRequiredevent. The components below show it.Register MentorAgent and its server, then map the endpoints:
using Novior.MentorAgent.Extensions; // AddMentorAgent using Novior.MentorAgent.Server.Extensions; // AddMentorAgentServer, MapMentorAgentServer using Microsoft.Extensions.AI; // AsIChatClient using OpenAI; // OpenAIClientbuilder.Services.AddMentorAgent(options => { options.AppName = "Case management"; options.ChatClient = new OpenAIClient(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Set OpenAI:Key with dotnet user-secrets.")) .GetChatClient("gpt-4o-mini") .AsIChatClient(); options.ScanAssemblies = [typeof(Program).Assembly]; }); builder.Services.AddMentorAgentServer(); // after var app = builder.Build(); app.MapMentorAgentServer();For another model provider, replace the
ChatClientline with one from Model providers.Connect the client: the next section has the same chat for React, Angular, Vue and JavaScript without a framework.
Connect the client
Section titled “Connect the client”Each version does the same things:
- it connects to
/mentor-hubwith@microsoft/signalr, and reconnects by itself withwithAutomaticReconnect(); - when the connection drops, it turns the input back on (
onreconnecting,onclose). Nothing more arrives for the turn in progress, and the new connection starts with no turn running; - it sends a message with
SendMessageand two arguments: the text, andnullfor a message without images; - it appends the answer as it streams (
StreamingChunk), and turns the input off and on withBusyChanged; - it shows
Error, which arrives for example when a message is too long or when the model provider fails; - it shows a confirmation (
ConfirmationRequired) and sends the user's answer withPOST /mentor/approve; - it shows an error if the server refuses the answer, except
404, which means that nothing waits for the answer any more; - it stops the turn with
POST /mentor/cancel.
The answer to a confirmation and Stop go over HTTP, not through the hub: SignalR runs one hub call at a time per connection, and SendMessage is still running while the turn waits.
React
A hook, useMentorChat, and a component that uses it:
import { useEffect, useRef, useState, type FormEvent } from 'react';
import * as signalR from '@microsoft/signalr';
export type MentorChatOptions = {
baseUrl?: string; // the backend's origin, such as https://api.example.com
getToken?: () => string | Promise<string>; // the signed-in user's access token
};
type Message = { role: 'user' | 'assistant'; text: string };
type Confirmation = { actionId: string; message: string };
export function useMentorChat({ baseUrl = '', getToken }: MentorChatOptions = {}) {
const connectionRef = useRef<signalR.HubConnection | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [confirmation, setConfirmation] = useState<Confirmation | null>(null);
useEffect(() => {
let active = true;
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${baseUrl}/mentor-hub`, { accessTokenFactory: getToken })
.withAutomaticReconnect()
.build();
connection.on('StreamingChunk', (chunk: string) =>
setMessages(list => [...list.slice(0, -1), { role: 'assistant', text: list[list.length - 1].text + chunk }]));
connection.on('BusyChanged', (value: boolean) => setBusy(value));
connection.on('Error', (message: string) => setError(message));
connection.on('ConfirmationRequired', (actionId: string, _toolName: string, message: string) =>
setConfirmation({ actionId, message }));
// The next connection has no turn in progress: when this one drops, turn the input back on
const idle = () => { if (active) { setBusy(false); setConfirmation(null); } };
connection.onreconnecting(idle);
connection.onclose(idle);
connection.start().catch(e => active && setError(String(e)));
connectionRef.current = connection;
return () => {
active = false;
connection.stop();
};
}, [baseUrl, getToken]);
async function headers(): Promise<HeadersInit> {
const token = await getToken?.();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function send(text: string) {
setError('');
setMessages(list => [...list, { role: 'user', text }, { role: 'assistant', text: '' }]);
// Always two arguments: null for a message without images
await connectionRef.current?.invoke('SendMessage', text, null).catch(e => setError(String(e)));
}
// Confirm and Stop go over HTTP: the hub runs one call at a time, and SendMessage is still running
async function confirm(approved: boolean) {
if (!confirmation) return;
setConfirmation(null);
const response = await fetch(`${baseUrl}/mentor/approve?actionId=${confirmation.actionId}&approved=${approved}`, {
method: 'POST',
headers: await headers(),
});
// 404: nothing waits for the answer any more (answered, stopped or disconnected), so there is nothing to report
if (!response.ok && response.status !== 404) setError(`The confirmation was not accepted: HTTP ${response.status}`);
}
async function stop() {
await fetch(`${baseUrl}/mentor/cancel?connectionId=${connectionRef.current?.connectionId}`, {
method: 'POST',
headers: await headers(),
});
}
return { messages, busy, error, confirmation, send, confirm, stop };
}
export function MentorChat(options: MentorChatOptions) {
const chat = useMentorChat(options);
const [input, setInput] = useState('');
function submit(event: FormEvent) {
event.preventDefault();
chat.send(input);
setInput('');
}
return (
<div className="mentor-chat">
{chat.messages.map((m, i) => <p key={i} className={m.role}>{m.text}</p>)}
{chat.error && <p role="alert">{chat.error}</p>}
{chat.confirmation && (
<div className="confirmation">
<p>{chat.confirmation.message}</p>
<button onClick={() => chat.confirm(true)}>Confirm</button>
<button onClick={() => chat.confirm(false)}>Cancel</button>
</div>
)}
<form onSubmit={submit}>
<input value={input} onChange={e => setInput(e.target.value)} disabled={chat.busy} />
{chat.busy
? <button type="button" onClick={chat.stop}>Stop</button>
: <button type="submit" disabled={!input.trim()}>Send</button>}
</form>
</div>
);
}Angular
A service that holds the connection, and a standalone component:
import { Injectable, InjectionToken, OnDestroy, inject, signal } from '@angular/core';
import * as signalR from '@microsoft/signalr';
export interface MentorChatOptions {
baseUrl?: string; // the backend's origin, such as https://api.example.com
getToken?: () => string | Promise<string>; // the signed-in user's access token
}
export const MENTOR_CHAT_OPTIONS = new InjectionToken<MentorChatOptions>('MentorChatOptions', {
factory: () => ({}),
});
type Message = { role: 'user' | 'assistant'; text: string };
type Confirmation = { actionId: string; message: string };
@Injectable({ providedIn: 'root' })
export class MentorChatService implements OnDestroy {
private readonly options = inject(MENTOR_CHAT_OPTIONS);
private readonly baseUrl = this.options.baseUrl ?? '';
private readonly connection = new signalR.HubConnectionBuilder()
.withUrl(`${this.baseUrl}/mentor-hub`, { accessTokenFactory: this.options.getToken })
.withAutomaticReconnect()
.build();
readonly messages = signal<Message[]>([]);
readonly busy = signal(false);
readonly error = signal('');
readonly confirmation = signal<Confirmation | null>(null);
constructor() {
this.connection.on('StreamingChunk', (chunk: string) =>
this.messages.update(list => [...list.slice(0, -1), { role: 'assistant', text: list[list.length - 1].text + chunk }]));
this.connection.on('BusyChanged', (value: boolean) => this.busy.set(value));
this.connection.on('Error', (message: string) => this.error.set(message));
this.connection.on('ConfirmationRequired', (actionId: string, _toolName: string, message: string) =>
this.confirmation.set({ actionId, message }));
// The next connection has no turn in progress: when this one drops, turn the input back on
const idle = () => { this.busy.set(false); this.confirmation.set(null); };
this.connection.onreconnecting(idle);
this.connection.onclose(idle);
this.connection.start().catch(e => this.error.set(String(e)));
}
async send(text: string) {
this.error.set('');
this.messages.update(list => [...list, { role: 'user', text }, { role: 'assistant', text: '' }]);
// Always two arguments: null for a message without images
await this.connection.invoke('SendMessage', text, null).catch(e => this.error.set(String(e)));
}
// Confirm and Stop go over HTTP: the hub runs one call at a time, and SendMessage is still running
async confirm(approved: boolean) {
const pending = this.confirmation();
if (!pending) return;
this.confirmation.set(null);
const response = await fetch(`${this.baseUrl}/mentor/approve?actionId=${pending.actionId}&approved=${approved}`, {
method: 'POST',
headers: await this.headers(),
});
// 404: nothing waits for the answer any more (answered, stopped or disconnected), so there is nothing to report
if (!response.ok && response.status !== 404) this.error.set(`The confirmation was not accepted: HTTP ${response.status}`);
}
async stop() {
await fetch(`${this.baseUrl}/mentor/cancel?connectionId=${this.connection.connectionId}`, {
method: 'POST',
headers: await this.headers(),
});
}
ngOnDestroy() {
this.connection.stop();
}
private async headers(): Promise<HeadersInit> {
const token = await this.options.getToken?.();
return token ? { Authorization: `Bearer ${token}` } : {};
}
}import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MentorChatService } from './mentor-chat.service';
@Component({
selector: 'app-mentor-chat',
standalone: true,
imports: [FormsModule],
template: `
<div class="mentor-chat">
@for (m of chat.messages(); track $index) {
<p [class]="m.role" [textContent]="m.text"></p>
}
@if (chat.error()) {
<p role="alert" [textContent]="chat.error()"></p>
}
@if (chat.confirmation(); as pending) {
<div class="confirmation">
<p [textContent]="pending.message"></p>
<button (click)="chat.confirm(true)">Confirm</button>
<button (click)="chat.confirm(false)">Cancel</button>
</div>
}
<form (ngSubmit)="submit()">
<input name="message" [(ngModel)]="input" [disabled]="chat.busy()" />
@if (chat.busy()) {
<button type="button" (click)="chat.stop()">Stop</button>
} @else {
<button type="submit" [disabled]="!input.trim()">Send</button>
}
</form>
</div>
`,
})
export class MentorChatComponent {
protected readonly chat = inject(MentorChatService);
protected input = '';
protected submit() {
this.chat.send(this.input);
this.input = '';
}
}Vue
A single-file component:
<script setup lang="ts">
import { onUnmounted, ref } from 'vue';
import * as signalR from '@microsoft/signalr';
const props = defineProps<{
baseUrl?: string; // the backend's origin, such as https://api.example.com
getToken?: () => string | Promise<string>; // the signed-in user's access token
}>();
const baseUrl = props.baseUrl ?? '';
const messages = ref<{ role: 'user' | 'assistant'; text: string }[]>([]);
const busy = ref(false);
const error = ref('');
const confirmation = ref<{ actionId: string; message: string } | null>(null);
const input = ref('');
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${baseUrl}/mentor-hub`, { accessTokenFactory: props.getToken })
.withAutomaticReconnect()
.build();
connection.on('StreamingChunk', (chunk: string) => { messages.value[messages.value.length - 1].text += chunk; });
connection.on('BusyChanged', (value: boolean) => { busy.value = value; });
connection.on('Error', (message: string) => { error.value = message; });
connection.on('ConfirmationRequired', (actionId: string, _toolName: string, message: string) => {
confirmation.value = { actionId, message };
});
// The next connection has no turn in progress: when this one drops, turn the input back on
const idle = () => { busy.value = false; confirmation.value = null; };
connection.onreconnecting(idle);
connection.onclose(idle);
connection.start().catch(e => { error.value = String(e); });
onUnmounted(() => connection.stop());
async function headers(): Promise<HeadersInit> {
const token = await props.getToken?.();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function submit() {
const text = input.value;
input.value = '';
error.value = '';
messages.value.push({ role: 'user', text }, { role: 'assistant', text: '' });
// Always two arguments: null for a message without images
await connection.invoke('SendMessage', text, null).catch(e => { error.value = String(e); });
}
// Confirm and Stop go over HTTP: the hub runs one call at a time, and SendMessage is still running
async function confirm(approved: boolean) {
const pending = confirmation.value;
if (!pending) return;
confirmation.value = null;
const response = await fetch(`${baseUrl}/mentor/approve?actionId=${pending.actionId}&approved=${approved}`, {
method: 'POST',
headers: await headers(),
});
// 404: nothing waits for the answer any more (answered, stopped or disconnected), so there is nothing to report
if (!response.ok && response.status !== 404) error.value = `The confirmation was not accepted: HTTP ${response.status}`;
}
async function stop() {
await fetch(`${baseUrl}/mentor/cancel?connectionId=${connection.connectionId}`, {
method: 'POST',
headers: await headers(),
});
}
</script>
<template>
<div class="mentor-chat">
<p v-for="(m, i) in messages" :key="i" :class="m.role" v-text="m.text"></p>
<p v-if="error" role="alert" v-text="error"></p>
<div v-if="confirmation" class="confirmation">
<p v-text="confirmation.message"></p>
<button @click="confirm(true)">Confirm</button>
<button @click="confirm(false)">Cancel</button>
</div>
<form @submit.prevent="submit">
<input v-model="input" :disabled="busy" />
<button v-if="busy" type="button" @click="stop">Stop</button>
<button v-else type="submit" :disabled="!input.trim()">Send</button>
</form>
</div>
</template>JavaScript
A module without a framework, and the page script that uses it. window.confirm stands in for your own dialog:
import * as signalR from '@microsoft/signalr';
export class MentorChat {
onChunk = chunk => {};
onBusy = busy => {};
onError = message => {};
onConfirmation = (actionId, message) => {};
// baseUrl: the backend's origin, such as https://api.example.com; getToken: the signed-in user's access token
constructor({ baseUrl = '', getToken } = {}) {
this.baseUrl = baseUrl;
this.getToken = getToken;
this.connection = new signalR.HubConnectionBuilder()
.withUrl(`${baseUrl}/mentor-hub`, { accessTokenFactory: getToken })
.withAutomaticReconnect()
.build();
this.connection.on('StreamingChunk', chunk => this.onChunk(chunk));
this.connection.on('BusyChanged', busy => this.onBusy(busy));
this.connection.on('Error', message => this.onError(message));
this.connection.on('ConfirmationRequired', (actionId, _toolName, message) => this.onConfirmation(actionId, message));
// The next connection has no turn in progress: when this one drops, turn the input back on
this.connection.onreconnecting(() => this.onBusy(false));
this.connection.onclose(() => this.onBusy(false));
}
start() {
return this.connection.start();
}
send(text) {
// Always two arguments: null for a message without images
return this.connection.invoke('SendMessage', text, null);
}
// Confirm and Stop go over HTTP: the hub runs one call at a time, and SendMessage is still running
async confirm(actionId, approved) {
const response = await fetch(`${this.baseUrl}/mentor/approve?actionId=${actionId}&approved=${approved}`, {
method: 'POST',
headers: await this.#headers(),
});
// 404: nothing waits for the answer any more (answered, stopped or disconnected), so there is nothing to report
if (!response.ok && response.status !== 404) this.onError(`The confirmation was not accepted: HTTP ${response.status}`);
}
async stop() {
await fetch(`${this.baseUrl}/mentor/cancel?connectionId=${this.connection.connectionId}`, {
method: 'POST',
headers: await this.#headers(),
});
}
async #headers() {
const token = await this.getToken?.();
return token ? { Authorization: `Bearer ${token}` } : {};
}
}import { MentorChat } from './mentor-chat.js';
// The page: <form id="chat"><input></form>, <button id="stop" hidden>Stop</button> and <p id="answer"></p>
const form = document.querySelector('#chat');
const input = form.querySelector('input');
const stop = document.querySelector('#stop');
const answer = document.querySelector('#answer');
const chat = new MentorChat();
chat.onChunk = chunk => { answer.textContent += chunk; };
chat.onBusy = busy => { input.disabled = busy; stop.hidden = !busy; };
chat.onError = message => { answer.textContent = message; };
chat.onConfirmation = (actionId, message) => chat.confirm(actionId, window.confirm(message));
chat.start().catch(error => { answer.textContent = String(error); });
form.addEventListener('submit', event => {
event.preventDefault();
if (!input.value.trim()) return;
answer.textContent = '';
chat.send(input.value).catch(error => { answer.textContent = String(error); });
input.value = '';
});
stop.addEventListener('click', () => chat.stop());The two settings, baseUrl and getToken, are optional: leave them out when the backend also serves the JavaScript app and users do not sign in. The relative URLs then work as they are. For a client on another origin, see the complete configuration.
A reconnect is a new connection, so it starts a new conversation. To keep it, save it with GET /mentor/session while the connection is alive, and restore it on the new connection with POST /mentor/session.
The snapshot is opaque. Store it as text, exactly as GET returned it, and send it back unchanged. Only the user who saved it can restore it. To restore after a server restart, or on another instance, the server must persist and share its Data Protection key ring (external site), as it does for authentication cookies. A snapshot that was edited, or that belongs to another user, is refused with 400.
The complete protocol, with every event and hub method, is in the Novior.MentorAgent.Server README on NuGet (external site).
The endpoints
Section titled “The endpoints”MapMentorAgentServer() maps these endpoints:
| Endpoint | What it does |
|---|---|
/mentor-hub | The SignalR hub: messages, streaming and every event |
GET /mentor/chat | One turn over SSE, with the message in the query string |
POST /mentor/chat | The same, with a JSON body that can carry images |
POST /mentor/approve | The answer to a confirmation: ?actionId=…&approved=true or false. It answers 404 when no turn waits for it any more |
POST /mentor/cancel | Stop, for a hub connection: ?connectionId=… |
GET and POST /mentor/session | Save and restore a hub connection's conversation: ?connectionId=… |
GET /mentor/tour | The onboarding tour steps, as JSON |
GET /mentor/admin/metrics | The token and cost snapshot, for an operator |
The MCP and A2A endpoints are mapped by their own calls: see MCP server and A2A server. AddMentorAgentServer() registers both when their options are on.
Complete configuration
Section titled “Complete configuration”A backend for a client on other origins, with signed-in users:
// The JavaScript app's origins: SignalR sends credentials, so no AllowAnyOrigin
builder.Services.AddCors(options => options.AddPolicy("MentorClients", policy => policy
.WithOrigins("http://localhost:5173", "http://localhost:4200") // Vite, Angular dev servers
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()));
// Plus your authentication scheme (JWT bearer, cookies): builder.Services.AddAuthentication()...
builder.Services.AddMentorAgent(options =>
{
options.AppName = "Case management";
options.AppDescription = "Support case management: case status, notes and closure";
options.ChatClient = new OpenAIClient(builder.Configuration["OpenAI:Key"]
?? throw new InvalidOperationException("Set OpenAI:Key with dotnet user-secrets."))
.GetChatClient("gpt-4o-mini")
.AsIChatClient();
options.ScanAssemblies = [typeof(Program).Assembly];
// Limits: a signed-in user is counted by ClaimTypes.NameIdentifier
options.MaxMessageLength = 2000;
options.RateLimitPerUser = 20;
options.EnableSafetyCheck = true;
// Role for GET /mentor/admin/metrics
options.DashboardRole = "Admin";
});
// Also registers MCP and A2A when their options are on, and passes the signed-in user of each request to MentorAgent
builder.Services.AddMentorAgentServer();
// after var app = builder.Build();
app.UseCors("MentorClients"); // before authentication and authorization
app.UseAuthentication();
app.UseAuthorization();
app.MapMentorAgentServer(configureTransports: e => e.RequireAuthorization());On the client, pass the backend's origin as baseUrl and a function that returns the user's access token as getToken. getAccessToken stands for the call of your sign-in library:
React
Pass them as props. Pass the same getToken function at every render, for example a function defined outside the component: a new function opens a new connection.
import { MentorChat } from './MentorChat';
import { getAccessToken } from './auth'; // your sign-in code
export function App() {
return <MentorChat baseUrl="https://api.example.com" getToken={getAccessToken} />;
}Angular
Provide them in the application configuration, from your sign-in service:
import { ApplicationConfig, inject } from '@angular/core';
import { MENTOR_CHAT_OPTIONS } from './mentor-chat.service';
import { AuthService } from './auth.service'; // your sign-in code
export const appConfig: ApplicationConfig = {
providers: [
{
provide: MENTOR_CHAT_OPTIONS,
useFactory: () => {
const auth = inject(AuthService);
return { baseUrl: 'https://api.example.com', getToken: () => auth.getAccessToken() };
},
},
],
};Vue
Pass them as props:
<script setup lang="ts">
import MentorChat from './MentorChat.vue';
import { getAccessToken } from './auth'; // your sign-in code
</script>
<template>
<MentorChat base-url="https://api.example.com" :get-token="getAccessToken" />
</template>JavaScript
Pass them to the constructor, in place of new MentorChat():
import { getAccessToken } from './auth.js'; // your sign-in code
const chat = new MentorChat({ baseUrl: 'https://api.example.com', getToken: getAccessToken });The client passes the token to the hub with accessTokenFactory, and sends it as an Authorization: Bearer header with POST /mentor/approve and POST /mentor/cancel. While the hub connection is open, the backend accepts those two requests only from the user who opened it: see Security and roles.
Options that matter here
Section titled “Options that matter here”| Option | Why it matters for a JavaScript client |
|---|---|
AnonymousIdentity | With the default, PerSession, every hub connection and every SSE request is a different anonymous user |
RateLimitPerUser | Counted per signed-in user. An anonymous caller gets a new allowance with every connection and every SSE request: require authentication to bound the spend |
UseMemoryContext, MemoryAutoCapture | With either on and /mentor/chat open to anyone, MentorAgent logs a warning at startup. MemoryAutoCapture is on by default |
EnableActionFeedback | Sends ActionExecuting and ActionCompleted while your actions run. ActionExecuting carries the text to show, such as the description of the action, not the tool name. ActionCompleted carries the tool name |
EnableGenerativeCards | Sends cards as the Cards event or the cards frame. The model does not repeat their content in text, so render them |
EnableRichResponses | On by default: the model writes its answers in Markdown, with tables and lists. The components on this page show them as plain text: render the Markdown safely, or set the option to false |
EnableImageInput | Accepts images on SendMessage and POST /mentor/chat. The hub's message size limit is raised to fit |
MaxHostedToolCallsPerSession | A cap on calls to the tools that run at the provider, such as web search. It counts per hub connection, but per request over SSE |
DashboardRole | The role GET /mentor/admin/metrics requires. An empty value opens it: development only |
Theme, BotName, EnableSuggestions and the other widget options | No effect: your client draws its own UI |
Every option is in Configuration options.
Other clients
Section titled “Other clients”Every client uses the same hub, the same SSE endpoint and the same protocol:
SendMessagetakes two arguments;- the answer to a confirmation and Stop go over HTTP (
/mentor/approveand/mentor/cancel), with the identity of the hub connection.
Native apps and other languages
Section titled “Native apps and other languages”Microsoft supports four SignalR clients. The minimum versions of each are in SignalR supported platforms (external site):
| Client | Package | Where it runs |
|---|---|---|
| JavaScript (external site) | @microsoft/signalr, on npm | Current browsers, on Android and iOS too, and Node.js |
| .NET (external site) | Microsoft.AspNetCore.SignalR.Client, on NuGet | Any platform ASP.NET Core supports; .NET MAUI apps use it on Android and iOS |
| Java (external site) | com.microsoft.signalr:signalr, on Maven | Java applications, Android apps included |
| Swift (external site) | SignalRClient, with Swift Package Manager | iOS and macOS apps |
The C++ client is for experimentation only, and Microsoft does not support it.
- A native mobile app uses the Java client on Android and the Swift client on iOS. A .NET MAUI app uses the .NET client: .NET MAUI explains when the agent stays on the server.
- A desktop app written in .NET uses the .NET client; one built with web technologies uses the JavaScript client.
- A script or a service in Node.js can use the JavaScript client too.
- Tokens. The .NET client takes the token from
AccessTokenProviderand the Java client fromwithAccessTokenProvider, and both send it in theAuthorizationheader. Send the same token with/mentor/approve,/mentor/canceland/mentor/session, from the platform's HTTP client.
Any language: Server-Sent Events
Section titled “Any language: Server-Sent Events”Where no SignalR client fits, such as a Python or shell script or a service in another language, use GET /mentor/chat: one turn, streamed as Server-Sent Events that any HTTP client can read. With curl:
curl -N "https://example.com/mentor/chat?message=How%20many%20cases%20are%20open%3F"Each frame is a data: line with a JSON object:
data: {"type":"chunk","text":"Two cases "}
data: {"type":"chunk","text":"are open."}
data: {"type":"completed"}chunkcarries a piece of the answer andcompletedends the turn.errorhas amessage, andcardsandnavigationcarry cards and a page change.- A gated action arrives as
confirmationRequired, withactionId,toolandmessage. The stream stays open until you answer withPOST /mentor/approve. - With authentication, add the token:
-H "Authorization: Bearer <your-token>". - To send images, use
POST /mentor/chatwith a JSON body,{"message": …, "attachments": […]}, and read the answer in the same way.
Other agents and tools: A2A and MCP
Section titled “Other agents and tools: A2A and MCP”An AI agent or tool does not need a chat client:
- Agent-to-Agent (A2A). Publish the whole agent with the A2A server. An agent, or an orchestrator that coordinates several agents, sends it a task in natural language and gets the answer.
- Model Context Protocol (MCP). Publish your actions with the MCP server. An AI application, such as an AI assistant in an editor, calls them as tools with its own model.
Both protocols have official SDKs for Python, JavaScript or TypeScript, Java, C#, Go and Rust: see the lists for MCP (external site) and for A2A (external site). The A2A server and MCP server pages cover authentication, and what happens to the actions that need a confirmation.
Security and roles
Section titled “Security and roles”- The transports are open until you protect them. Anyone who reaches
/mentor-hubor/mentor/chatruns turns at your expense.configureTransports: e => e.RequireAuthorization()covers the hub and both/mentor/chatendpoints. How to protect each endpoint is in Secure the endpoints. - Who the user is.
AddMentorAgentServer()passes the hub's or the request's authenticated user to MentorAgent, soRequiredRoles, per-user memory and the rate limit work for any client. Without authentication, every action withRequiredRolesis refused. If the app also callsAddRazorComponents(), the callers of the hub and of/mentor/chatare still known: MentorAgent reads their user from the request. To add claims or roles, see Secure the endpoints. Memory and the rate limit identify the user by theNameIdentifierclaim, elsesub,oidor the name. - Tokens in the browser. Browsers cannot set an
Authorizationheader on WebSocket and Server-Sent Events connections, so there the JavaScript client sends the token in theaccess_tokenquery string. With JSON Web Token (JWT) bearer the server must read it from there for/mentor-hub: see Choose the authentication scheme. /mentor/approve,/mentor/canceland/mentor/sessioncheck the owner. While a signed-in user's hub connection is open, only that user can use them: an anonymous request gets401, another user403. Send the same token, as the components on this page do, or the cookies withcredentials: 'include'. The owner is recognized byNameIdentifier, elsesub,oidor the name.- The session snapshot is opaque. Store it as text and send it back unchanged. Only the user who saved it can restore it. See Secure the endpoints.
- Confirmations over SSE have no owner. Whoever holds the
actionIdcan answer them, so keep/mentor/chatauthenticated. What each channel does with confirmations is in Where nobody can answer.
Common errors
Section titled “Common errors”- "Failed to invoke 'SendMessage' due to an error on the server".
SendMessagetakes two arguments. Passnullas the second one for a text-only message: SignalR does not use the default value of the C# parameter. - The client never connects from another origin. The browser blocked the handshake. List every client origin and call
AllowCredentials():AllowAnyOrigin()cannot be combined with credentials. CallUseCorsbeforeUseAuthentication()andUseAuthorization(), and write those two calls inProgram.csyourself. Otherwise authorization can refuse the browser's preflight request. - The chat freezes when a confirmation appears. The client answered with the
RespondToApprovalhub method. SignalR runs one hub call at a time per connection, andSendMessageis still waiting: answer withPOST /mentor/approve. - Stop works only after the answer is complete. For the same reason, use
POST /mentor/cancel, not theCancelRequesthub method. A404means the server no longer has that connection, for example while the client reconnects. POST /mentor/approveanswers404. Nothing waits for the answer any more: it was answered already, the turn was stopped, or the connection closed. Close the dialog. It is not a fault, and the components on this page do not show an error for it.- After a reconnect the input stays disabled. The connection dropped during a turn, and
BusyChanged(false)never arrives on the new one. Turn the input back on inonreconnectingandonclose, as the components on this page do. /mentor/approve,/mentor/cancelor/mentor/sessionanswer401or403. The request does not carry the identity of the hub connection.POST /mentor/sessionanswers400or415. A400means that the snapshot was edited, belongs to another user, or lost its Data Protection keys on the server. Send back exactly whatGETreturned. A415means that the body has the wrong content type: sendapplication/json.- A follow-up question over SSE has no context. Every
/mentor/chatrequest is its own conversation. Use the hub for a chat. - Card colors and button kinds arrive as numbers. Over SignalR, enums are numbers; only the SSE
cardsframe uses strings. - The input is enabled while a confirmation is still pending.
StreamingCompletedalso fires before a confirmation. Enable the input onBusyChanged(false), as the components on this page do. - The React component opens a new connection at every render.
getTokenis a new function each time, for example an arrow function written in the JSX. Define it outside the component, or keep it withuseCallback.
See also
Section titled “See also”- Channels: how clients reach the agent — Which channel fits your client: the Blazor or MAUI widget, SignalR or SSE for JavaScript and mobile apps, and MCP or A2A for other agents and AI tools.
- Secure the endpoints — Protect the SignalR hub, Server-Sent Events, /mcp and /a2a endpoints with authentication and CORS before you expose them.
- .NET MAUI (Blazor Hybrid) — Host MentorAgent in a .NET MAUI Blazor Hybrid app, pass the signed-in user for role checks, and keep the model key on a server.
- Configuration options — Every MentorOptions property with its type, default and effect, grouped by area, plus the options of the WebAssembly client and the YAML loader.
