MentorAgent Docs
Menu

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

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.

View as Markdown
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.

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 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.0.0" />
</ItemGroup>

Central Package Management

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

File-based app

app.cs
#:package Novior.MentorAgent.Server@1.0.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
    using Novior.MentorAgent.Server.Extensions;   // AddMentorAgentServer, MapMentorAgentServer
    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 version 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). 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 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 = {
  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:

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

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 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}` } : {};
  }
}
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 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).

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

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

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:

  • SendMessage takes two arguments;
  • the answer to a confirmation and Stop go over HTTP (/mentor/approve and /mentor/cancel), with the identity of the hub connection.

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%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"}
  • chunk carries a piece of the answer and completed ends the turn. 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:

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

  • The transports are open until you protect them. Anyone who reaches /mentor-hub or /mentor/chat runs turns at your expense. 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 hub's or the request's authenticated user 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. To add claims or roles, see Secure the endpoints. Memory and the rate limit identify the user by the NameIdentifier claim, else sub, oid or the name.
  • 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 NameIdentifier, else sub, oid or 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 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. 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.
  • 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 carry the identity of the hub connection.
  • 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.
  • 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.