Questa pagina non è ancora tradotta in italiano: qui la trovi in inglese.
Any front end: React, Angular, Vue
Build the chat in React, Angular, Vue or a mobile app with a .NET AI agent behind it: SignalR or Server-Sent Events, confirmation over HTTP, stopping an answer.
On this page
Use this page when your front end is React, Angular, Vue or a mobile app, with a .NET AI agent behind it. 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 also be other JavaScript, a desktop app or a script: anything that is not Blazor. It talks to the backend over SignalR, the ASP.NET Core library for real-time connections, or over Server-Sent Events (SSE). Other agents and tools, in any language, can reach the same backend over the Agent-to-Agent (A2A) and Model Context Protocol (MCP) protocols.
When to choose it
Section titled “When to choose it”Choose this setup 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.2.0" />
</ItemGroup>Central Package Management
<ItemGroup>
<PackageVersion Include="Novior.MentorAgent.Server" Version="1.2.0" />
</ItemGroup><ItemGroup>
<PackageReference Include="Novior.MentorAgent.Server" />
</ItemGroup>File-based app
#:package Novior.MentorAgent.Server@1.2.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 // AddMentorAgentServer, MapMentorAgentServer using Novior.MentorAgent.Server.Extensions; 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 component below 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); - after a reconnect, 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 registers
ReplyStarted, which the hub sends before each reply to mark it as written by AI. The handler can do nothing, but without one SignalR logs a warning for every reply; - 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 = {
// the backend's origin, such as https://api.example.com
baseUrl?: string;
// the signed-in user's access token
getToken?: () => string | Promise<string>;
};
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 hub announces each reply as written by AI: without a handler,
// SignalR logs a warning for every reply
connection.on('ReplyStarted', (_reply: { aiGenerated: boolean }) => {});
// 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 { actionId } = confirmation;
const response = await fetch(
`${baseUrl}/mentor/approve?actionId=${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() {
const connectionId = connectionRef.current?.connectionId;
await fetch(`${baseUrl}/mentor/cancel?connectionId=${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 {
// the backend's origin, such as https://api.example.com
baseUrl?: string;
// the signed-in user's access token
getToken?: () => string | Promise<string>;
}
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 hub announces each reply as written by AI: without a handler,
// SignalR logs a warning for every reply
this.connection.on('ReplyStarted',
(_reply: { aiGenerated: boolean }) => {});
// 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 { baseUrl } = this;
const { actionId } = pending;
const response = await fetch(
`${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.error.set(
`The confirmation was not accepted: HTTP ${response.status}`);
}
}
async stop() {
const connectionId = this.connection.connectionId;
await fetch(`${this.baseUrl}/mentor/cancel?connectionId=${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<{
// the backend's origin, such as https://api.example.com
baseUrl?: string;
// the signed-in user's access token
getToken?: () => string | Promise<string>;
}>();
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 hub announces each reply as written by AI: without a handler,
// SignalR logs a warning for every reply
connection.on('ReplyStarted', (_reply: { aiGenerated: boolean }) => {});
// 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 { actionId } = pending;
const response = await fetch(
`${baseUrl}/mentor/approve?actionId=${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() {
const connectionId = connection.connectionId;
await fetch(`${baseUrl}/mentor/cancel?connectionId=${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 hub announces each reply as written by AI: without a handler,
// SignalR logs a warning for every reply
this.connection.on('ReplyStarted', _reply => {});
// 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 { baseUrl } = this;
const response = await fetch(
`${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() {
const connectionId = this.connection.connectionId;
await fetch(`${this.baseUrl}/mentor/cancel?connectionId=${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 components accept two optional settings, baseUrl and getToken. 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.
The complete protocol, with every event and hub method, is in the Novior.MentorAgent.Server README on NuGet (external site).
Keep the conversation after a reconnect
Section titled “Keep the conversation after a reconnect”A reconnect is a new connection, so it starts a new conversation. To keep the conversation, save a snapshot of it with GET /mentor/session while the connection is alive. Restore the snapshot on the new connection with POST /mentor/session.
The client cannot read or change the snapshot. 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 restart, or on another instance, persist and share the ASP.NET Core Data Protection key ring (external site), as you do for authentication cookies. A snapshot that was edited, or that belongs to another user, is refused with 400.
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 figures, 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
// The Vite and Angular dev servers
.WithOrigins("http://localhost:5173", "http://localhost:4200")
.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 is a placeholder for the call that your sign-in library provides:
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 for a JavaScript client
Section titled “Options for a JavaScript client”| 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 as the components above.
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"Each frame is a data: line with a JSON object:
data: {"type":"chunk","text":"Two cases ","aiGenerated":true}
data: {"type":"chunk","text":"are open.","aiGenerated":true}
data: {"type":"completed","aiGenerated":true}chunkcarries a piece of the answer andcompletedends the turn. Both carry"aiGenerated": true, the machine-readable mark that the text was written by AI.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:
- A2A. Publish the whole agent with the A2A server. An agent, or an orchestrator (a program that splits a job among several agents), sends it a task in natural language and gets the answer.
- 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 paid turns on your model.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 authenticated user of the hub or of the request to MentorAgent. SoRequiredRoles, per-user memory and the rate limit work for any client.- Without authentication, every action with
RequiredRolesis refused. - If the app also calls
AddRazorComponents(), the callers of the hub and of/mentor/chatare still known: MentorAgent reads their user from the request. - Memory and the rate limit identify the user by the
NameIdentifierclaim or, if that claim is missing,sub,oidor the name. - To add claims or roles, see Secure the endpoints.
- 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 by theNameIdentifierclaim or, if that claim is missing, bysub,oidor the name.- 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 with a Cross-Origin Resource Sharing (CORS) error. 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. This is a request that the browser sends before the real request, and it carries no credentials. - 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 come from the user who opened the hub connection. Send the same token as the hub connection, or the same cookies.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.
Vedi anche
Sezione “Vedi anche”- Canali: come i client arrivano all'agente — Scegli come i client raggiungono il tuo agente: il widget della chat, SignalR o HTTP dal tuo front end, o un protocollo per altri agenti e strumenti AI.
- 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.
