MentorAgent Docs
Menu

Questa pagina non è ancora tradotta in italiano: qui la trovi in inglese.

How-to guidePackages: Novior.MentorAgent.Server, Novior.MentorAgent

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.

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 hubServer-Sent Events
Endpoint/mentor-hubGET or POST /mentor/chat
Client libraryA SignalR client, such as @microsoft/signalrNone: fetch, curl or any HTTP client
ConversationOne per connection, across messagesEvery request is a new conversation
Page context and UI actionsYesNo
What arrivesEvery event: text, busy state, actions, confirmations, navigation, citations, cards, team activityText, completion, errors, cards, navigation and confirmation requests

Use the hub for a chat. Use SSE for a single question with a streamed answer.

In the ASP.NET Core project, Novior.MentorAgent.Server brings Novior.MentorAgent with it:

.NET CLI

Terminal
dotnet add package Novior.MentorAgent.Server

PackageReference

YourApp.csproj
<ItemGroup>
  <PackageReference Include="Novior.MentorAgent.Server" Version="1.2.0" />
</ItemGroup>

Central Package Management

Directory.Packages.props
<ItemGroup>
  <PackageVersion Include="Novior.MentorAgent.Server" Version="1.2.0" />
</ItemGroup>
YourApp.csproj
<ItemGroup>
  <PackageReference Include="Novior.MentorAgent.Server" />
</ItemGroup>

File-based app

app.cs
#:package Novior.MentorAgent.Server@1.2.0

Neither package includes a model provider package. The server code on this page uses OpenAI, so add its package to the same project:

Terminal
dotnet add package Microsoft.Extensions.AI.OpenAI --version 10.6.0

For another provider, see Model providers.

In the JavaScript project, for the hub:

Terminal
npm install @microsoft/signalr

Server-Sent Events need no library. Clients in other languages are in Other clients.

  1. Keep the model key out of the source code:

    Terminal
    dotnet user-secrets init
    dotnet user-secrets set OpenAI:Key <your-key>
  2. 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 a ConfirmationRequired event. The components below show it.

  3. Register MentorAgent and its server, then map the endpoints:

    Program.cs
    using Novior.MentorAgent.Extensions;   // AddMentorAgent
    // AddMentorAgentServer, MapMentorAgentServer
    using Novior.MentorAgent.Server.Extensions;
    using Microsoft.Extensions.AI;         // AsIChatClient
    using OpenAI;                          // OpenAIClient
    Program.cs
    builder.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 ChatClient line with one from Model providers.

  4. Connect the client: the next section has the same chat for React, Angular, Vue and JavaScript without a framework.

Each component below does the same things:

  • it connects to /mentor-hub with @microsoft/signalr, and reconnects by itself with withAutomaticReconnect();
  • 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 SendMessage and two arguments: the text, and null for a message without images;
  • it appends the answer as it streams (StreamingChunk), and turns the input off and on with BusyChanged;
  • 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 with POST /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:

MentorChat.tsx
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:

mentor-chat.service.ts
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}` } : {};
  }
}
mentor-chat.component.ts
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:

MentorChat.vue
<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:

mentor-chat.js
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}` } : {};
  }
}
main.js
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).

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.

MapMentorAgentServer() maps these endpoints:

EndpointWhat it does
/mentor-hubThe SignalR hub: messages, streaming and every event
GET /mentor/chatOne turn over SSE, with the message in the query string
POST /mentor/chatThe same, with a JSON body that can carry images
POST /mentor/approveThe answer to a confirmation: ?actionId=…&approved=true or false. It answers 404 when no turn waits for it any more
POST /mentor/cancelStop, for a hub connection: ?connectionId=…
GET and POST /mentor/sessionSave and restore a hub connection's conversation: ?connectionId=…
GET /mentor/tourThe onboarding tour steps, as JSON
GET /mentor/admin/metricsThe 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.

A backend for a client on other origins, with signed-in users:

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

App.tsx
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:

app.config.ts
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:

App.vue
<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():

main.js
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.

OptionWhy it matters for a JavaScript client
AnonymousIdentityWith the default, PerSession, every hub connection and every SSE request is a different anonymous user
RateLimitPerUserCounted 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, MemoryAutoCaptureWith either on and /mentor/chat open to anyone, MentorAgent logs a warning at startup. MemoryAutoCapture is on by default
EnableActionFeedbackSends 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
EnableGenerativeCardsSends cards as the Cards event or the cards frame. The model does not repeat their content in text, so render them
EnableRichResponsesOn 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
EnableImageInputAccepts images on SendMessage and POST /mentor/chat. The hub's message size limit is raised to fit
MaxHostedToolCallsPerSessionA 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
DashboardRoleThe role GET /mentor/admin/metrics requires. An empty value opens it: development only
Theme, BotName, EnableSuggestions and the other widget optionsNo effect: your client draws its own UI

Every option is in Configuration options.

Every client uses the same hub, the same SSE endpoint and the same protocol as the components above.

Microsoft supports four SignalR clients. The minimum versions of each are in SignalR supported platforms (external site):

ClientPackageWhere it runs
JavaScript (external site)@microsoft/signalr, on npmCurrent browsers, on Android and iOS too, and Node.js
.NET (external site)Microsoft.AspNetCore.SignalR.Client, on NuGetAny platform ASP.NET Core supports; .NET MAUI apps use it on Android and iOS
Java (external site)com.microsoft.signalr:signalr, on MavenJava applications, Android apps included
Swift (external site)SignalRClient, with Swift Package ManageriOS 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 AccessTokenProvider and the Java client from withAccessTokenProvider, and both send it in the Authorization header. Send the same token with /mentor/approve, /mentor/cancel and /mentor/session, from the platform's HTTP client.

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:

Terminal
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}
  • chunk carries a piece of the answer and completed ends the turn. Both carry "aiGenerated": true, the machine-readable mark that the text was written by AI. error has a message, and cards and navigation carry cards and a page change.
  • A gated action arrives as confirmationRequired, with actionId, tool and message. The stream stays open until you answer with POST /mentor/approve.
  • With authentication, add the token: -H "Authorization: Bearer <your-token>".
  • To send images, use POST /mentor/chat with a JSON body, {"message": …, "attachments": […]}, and read the answer in the same way.

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.

  • The transports are open until you protect them. Anyone who reaches /mentor-hub or /mentor/chat runs paid turns on your model. configureTransports: e => e.RequireAuthorization() covers the hub and both /mentor/chat endpoints. 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. So RequiredRoles, per-user memory and the rate limit work for any client.
    • Without authentication, every action with RequiredRoles is refused.
    • If the app also calls AddRazorComponents(), the callers of the hub and of /mentor/chat are still known: MentorAgent reads their user from the request.
    • Memory and the rate limit identify the user by the NameIdentifier claim or, if that claim is missing, sub, oid or the name.
    • To add claims or roles, see Secure the endpoints.
  • Tokens in the browser. Browsers cannot set an Authorization header on WebSocket and Server-Sent Events connections, so there the JavaScript client sends the token in the access_token query 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/cancel and /mentor/session check the owner. While a signed-in user's hub connection is open, only that user can use them: an anonymous request gets 401, another user 403. Send the same token, as the components on this page do, or the cookies with credentials: 'include'. The owner is recognized by the NameIdentifier claim or, if that claim is missing, by sub, oid or the name.
  • Confirmations over SSE have no owner. Whoever holds the actionId can answer them, so keep /mentor/chat authenticated. What each channel does with confirmations is in Where nobody can answer.
  • "Failed to invoke 'SendMessage' due to an error on the server". SendMessage takes two arguments. Pass null as 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. Call UseCors before UseAuthentication() and UseAuthorization(), and write those two calls in Program.cs yourself. 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 RespondToApproval hub method. SignalR runs one hub call at a time per connection, and SendMessage is still waiting: answer with POST /mentor/approve.
  • Stop works only after the answer is complete. For the same reason, use POST /mentor/cancel, not the CancelRequest hub method. A 404 means the server no longer has that connection, for example while the client reconnects.
  • POST /mentor/approve answers 404. 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 in onreconnecting and onclose, as the components on this page do.
  • /mentor/approve, /mentor/cancel or /mentor/session answer 401 or 403. 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/session answers 400 or 415. A 400 means that the snapshot was edited, belongs to another user, or lost its Data Protection keys on the server. Send back exactly what GET returned. A 415 means that the body has the wrong content type: send application/json.
  • A follow-up question over SSE has no context. Every /mentor/chat request 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 cards frame uses strings.
  • The input is enabled while a confirmation is still pending. StreamingCompleted also fires before a confirmation. Enable the input on BusyChanged(false), as the components on this page do.
  • The React component opens a new connection at every render. getToken is a new function each time, for example an arrow function written in the JSX. Define it outside the component, or keep it with useCallback.
  • 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.